Skip to content

Commit 51d96c1

Browse files
committed
meta: add guidelines for introduction of ERM support
1 parent b981253 commit 51d96c1

File tree

1 file changed

+307
-0
lines changed

1 file changed

+307
-0
lines changed

doc/contributing/erm-guidelines.md

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
# Explicit Resource Management (ERM) Guidelines
2+
3+
Explicit Resource Management is a capability that was introduced to the JavaScript
4+
langauge 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 ERM in the Node.js
27+
project -- specifically, guidelines around how to make objects disposable and
28+
how to introduce ERM capabilities into existing APIs.
29+
30+
## Some background
31+
32+
Objects can be made disposable by implementing either, or both, the
33+
`Symbol.dispose` and `Symbol.asyncDispose` methods:
34+
35+
```js
36+
class MyResource {
37+
[Symbol.dispose]() {
38+
// Synchronous disposal logic
39+
}
40+
41+
async [Symbol.asyncDispose]() {
42+
// Asynchronous disposal logic
43+
}
44+
}
45+
```
46+
47+
An object that implements `Symbol.dispose` can be used with the `using`
48+
statement, which will automatically call the `Symbol.dispose` method when the
49+
object goes out of scope. If an object implements `Symbol.asyncDispose`, it can
50+
be used with the `await using` statement in an asynchronous context.
51+
52+
```mjs
53+
{
54+
using resource = new MyResource();
55+
await using asyncResource = new MyResource();
56+
}
57+
```
58+
59+
Importantly, it is necessary to understand that the design of ERM makes it
60+
possible for user code to call the `Symbol.dispose` or `Symbol.asyncDispose`
61+
methods directly, outside of the `using` or `await using` statements. These
62+
can also be called multiple times and by any code that is holding a reference
63+
to the object. That is to say, ERM does not imply ownership of the object. It
64+
is not a form of RAII (Resource Acquisition Is Initialization) as seen in some
65+
other languages and there is no notion of exclusive ownership of the object.
66+
A disposable object can become disposed at any time.
67+
68+
The `Symbol.dispose` and `Symbol.asyncDispose` methods are called in both
69+
successful and exceptional exits from the scopes in which the using keyword
70+
is used. This means that if an exception is thrown within the scope, the
71+
disposal methods will still be called. However, when the disposal methods are
72+
called they are not aware of the context in which they were called. These
73+
methods will not receive any information about the exception that was thrown
74+
or whether an exception was thrown at all. This means that it is safest to
75+
assume that the disposal methods will be called in a context where the object
76+
may not be in a valid state or that an exception may be pending.
77+
78+
## Guidelines for Disposable Objects
79+
80+
So with this is mind, it is necessary to outline some guidelines for disposers:
81+
82+
1. Disposers should be idempotent. Multiple calls to the disposal methods
83+
should not cause any issues or have any additional side effects.
84+
2. Disposers should assume that it is being called in an exception context.
85+
Always assume there is likely a pending exception and that if the object
86+
has not been explicitly closed when the disposal method is called, that
87+
the object should be disposed as if an exception had occurred. For instance,
88+
if the object API exposes both a `close()` method and an `abort()` method,
89+
the disposal method should call `abort()` if the object is not already
90+
closed. If there is no difference in disposing in success or exception
91+
contexts, then separate disposal methods are unnecessary.
92+
3. Disposers may throw their own exceptions but this is not recommended.
93+
If a disposer throws an exception while there is another pending
94+
exception, then both exceptions will be wrapped in a `SupressedError`
95+
that masks both. This makes it difficult to understand the context
96+
in which the exceptions were thrown. It is, however, not possible to
97+
completely prevent exceptions from being thrown in the disposal methods
98+
so this guideline is more of a recommendation than a hard rule.
99+
4. Disposable objects should expose explicit disposal methods in addition
100+
to the `Symbol.dispose` and `Symbol.asyncDispose` methods. This allows
101+
user code to explicitly dispose of the object without using the `using`
102+
or `await using` statements. For example, a disposable object might
103+
expose a `close()` method that can be called to dispose of the object.
104+
The `Symbol.dispose` and `Symbol.asyncDispose` methods should delegate to
105+
these explicit disposal methods.
106+
5. Because it is safest to assume that the disposal method will be called
107+
in an exception context, it is strongly recommended to just generally avoid
108+
use of `Symbol.asyncDispose` as much as possible. Asynchronous disposal can
109+
lead delaying the handling of exceptions and can make it difficult to
110+
reason about the state of the object while the disposal is in progress and
111+
is often an anti-pattern. Disposal in an exception context should always
112+
be synchronous and immediate.
113+
114+
### Example Disposable Object
115+
116+
```js
117+
class MyDisposableResource {
118+
constructor() {
119+
this.closed = false;
120+
}
121+
122+
doSomething() {
123+
if (maybeShouldThrow()) {
124+
throw new Error('Something went wrong');
125+
}
126+
}
127+
128+
close() {
129+
// Gracefully close the resource.
130+
if (this.closed) return;
131+
this.closed = true;
132+
console.log('Resource closed');
133+
}
134+
135+
abort(maybeError) {
136+
// Abort the resource, optionally with an exception. Calling this
137+
// method multiple times should not cause any issues or additional
138+
// side effects.
139+
if (this.closed) return;
140+
this.closed = true;
141+
if (maybeError) {
142+
console.error('Resource aborted due to error:', maybeError);
143+
} else {
144+
console.log('Resource aborted');
145+
}
146+
}
147+
148+
[Symbol.dispose]() {
149+
// Note that when this is called, we cannot pass any pending
150+
// exceptions to the abort method because we do not know if
151+
// there is a pending exception or not.
152+
this.abort();
153+
}
154+
}
155+
```
156+
157+
Then in use:
158+
159+
```js
160+
{
161+
using resource = new MyDisposableResource();
162+
// Do something with the resource that might throw an error
163+
resource.doSomething();
164+
resource.close();
165+
}
166+
```
167+
168+
Here, if an error is thrown in the `doSomething()` method, the `Symbol.dispose`
169+
method will still be called when the block exits, ensuring that the resource is
170+
disposed of properly using the `abort()` method. If no error is thrown, the
171+
`close()` method is called explicitly to gracefully close the resource. When the
172+
block exits, the `Symbol.dispose` method is still called but it will be a non-op
173+
since the resource has already been closed.
174+
175+
To deal with errors that may occur during disposal, it is necessary to wrap
176+
the disposal block in a try-catch:
177+
178+
```js
179+
try {
180+
using resource = new MyDisposableResource();
181+
// Do something with the resource that might throw an error
182+
resource.doSomething();
183+
resource.close();
184+
} catch (error) {
185+
// Error might be the actual error thrown in the block, or might
186+
// be a SupressedError if an error was thrown during disposal and
187+
// there was a pending exception already.
188+
if (error instanceof SuppressedError) {
189+
console.error('An error occurred during disposal masking pending error:',
190+
error.error, error.suppressed);
191+
} else {
192+
console.error('An error occurred:', error);
193+
}
194+
}
195+
```
196+
197+
## Guidelines for Introducing ERM into Existing APIs
198+
199+
Introducing ERM capabilities into existing APIs can be tricky.
200+
201+
The best way to understand the issues is to look at a real world example. PR
202+
[58516](https://github.com/nodejs/node/pull/58516) is a good case. This PR
203+
sought to introduce ERM capabilities into the `fs.mkdtemp` API such that a
204+
temporary directory could be created and automatically disposed of when the
205+
scope in which it was created exited. However, the existing implementation of
206+
the `fs.mkdtemp` API returns a string value that cannot be made disposable.
207+
There are also sync, callback, and promise-based variations of the existing
208+
API that further complicate the situation.
209+
210+
In the initial proposal, the `fs.mkdtemp` API was changed to return an object
211+
that implements the `Symbol.dispose` method but only if a specific option is
212+
provided. This would mean that the return value of the API would become
213+
polymorphic, returning different types based on how it was called. This adds
214+
a lot of complexity to the API and makes it difficult to reason about the
215+
return value. It also makes it difficult to programmatically detect whether
216+
the version of the API being used supports ERM capabilities or not. That is,
217+
`fs.mkdtemp('...', { dispoable: true })` would act differently in older versions
218+
of Node.js than in newer versions with no way to detect this at runtime other
219+
than to inspect the return value.
220+
221+
Some APIs that already return object that can be made disposable do not have
222+
this kind of issue. For example, the `setTimeout` API in Node.js returns an
223+
object that implements the `Symbol.dispose` method. This change was made without
224+
much fanfare because the return value of the API was already an object.
225+
226+
So, some APIs can be made disposable easily without any issues while others
227+
require more thought and consideration. The following guidelines can help
228+
when introducing ERM capabilities into existing APIs:
229+
230+
1. Avoid polymorphic return values: If an API already returns a value that
231+
can be made disposable and it makes sense to make it disposable, do so. Do
232+
not, however, make the return value polymorphic determined by an option
233+
passed into the API.
234+
2. Introduce new API variants that are ERM capable: If an existing API
235+
cannot be made disposable without changing the return type or making it
236+
polymorphic, consider introducing a new API variant that is ERM capable.
237+
For example, `fs.mkdtempDisposable` could be introduced to return a
238+
disposable object while the existing `fs.mkdtemp` API continues to return
239+
a string. Yes, it means more APIs to maintain but it avoids the complexity
240+
and confusion of polymorphic return values.
241+
3. If adding a new API variant is not ideal, remember that changing the
242+
return type of an existing API is a breaking change.
243+
244+
## Guidelines for using disposable objects
245+
246+
Because disposable objects can be disposed of at any time, it is important
247+
to be careful when using them. Here are some guidelines for using disposable:
248+
249+
1. Never use `using` or `await using` with disposable objects that you
250+
do not own. For instance, the following code is problematic if you
251+
are not the owner of `someObject`:
252+
253+
```js
254+
function foo(someObject) {
255+
using resource = someObject;
256+
}
257+
```
258+
259+
The reason this is problematic is that the `using` statement will
260+
call the `Symbol.dispose` method on `someObject` when the block exits,
261+
but you do not control the lifecycle of `someObject`. If `someObject`
262+
is disposed of, it may lead to unexpected behavior in the rest of the
263+
code that called the `foo` function.
264+
265+
2. Always explicitly dispose of objects in successful code paths, including
266+
early returns. For example:
267+
268+
```js
269+
function foo() {
270+
using res = new MyDisposableResource();
271+
if (someCondition) {
272+
// Early return, ensure the resource is disposed of
273+
res.close();
274+
return;
275+
}
276+
// do other stuff
277+
res.close();
278+
}
279+
```
280+
281+
3. Remember that disposers are invoked in a stack, in the reverse order
282+
in which there were created. For example,
283+
284+
```js
285+
class MyDisposable {
286+
constructor(name) {
287+
this.name = name;
288+
}
289+
[Symbol.dispose]() {
290+
console.log(`Disposing ${this.name}`);
291+
}
292+
}
293+
294+
{
295+
using a = new MyDisposable('A');
296+
using b = new MyDisposable('B');
297+
using c = new MyDisposable('C');
298+
// When this block exits, the disposal methods will be called in the
299+
// reverse order: C, B, A.
300+
}
301+
```
302+
303+
Because of this, it is important to consider the possible relationships
304+
between disposable objects. For example, if one disposable object holds a
305+
reference to another disposable object the cleanup order may be important.
306+
If disposers are properly idempotent, however, this should not cause any
307+
issue, but it still requires careful consideration.

0 commit comments

Comments
 (0)