Skip to content

Commit 4c43e17

Browse files
committed
util: add util.disposer helper to wrap a dispose function
Add `util.disposer` and `util.asyncDisposer` to conveniently wrap a function to be a disposable, and allow it to be used with `using` declaration.
1 parent 7ffa029 commit 4c43e17

File tree

6 files changed

+300
-0
lines changed

6 files changed

+300
-0
lines changed

doc/api/util.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3655,6 +3655,89 @@ Returns `true` if the value is a built-in {WeakSet} instance.
36553655
util.types.isWeakSet(new WeakSet()); // Returns true
36563656
```
36573657
3658+
## Disposer APIs
3659+
3660+
> Stability: 1 - Experimental
3661+
3662+
### `util.asyncDisposer(disposeFn)`
3663+
3664+
<!-- YAML
3665+
added: REPLACEME
3666+
-->
3667+
3668+
* `disposeFn` {Function} A dispose function returning a promise
3669+
* Returns: {AsyncDisposer}
3670+
3671+
Create a convenient wrapper on the given async function that can be used
3672+
with `using` declaration.
3673+
3674+
If an error is thrown in the function, instead of returning a promise,
3675+
the error will be wrapped in a rejected promise.
3676+
3677+
```mjs
3678+
{
3679+
await using _ = util.disposer(async function disposer() {
3680+
// Performing async disposing actions...
3681+
});
3682+
3683+
// Doing some works...
3684+
3685+
} // When this scope exits, the disposer function is invoked and awaited.
3686+
```
3687+
3688+
### `util.disposer(disposeFn)`
3689+
3690+
<!-- YAML
3691+
added: REPLACEME
3692+
-->
3693+
3694+
* `disposeFn` {Function} A dispose function
3695+
* Returns: {Disposer}
3696+
3697+
Create a convenient wrapper on the given function that can be used with
3698+
`using` declaration.
3699+
3700+
```js
3701+
{
3702+
using _ = util.disposer(function disposer() {
3703+
// Performing disposing actions...
3704+
});
3705+
3706+
// Doing some works...
3707+
3708+
} // When this scope exits, the disposer function is invoked.
3709+
```
3710+
3711+
### Class: `util.AsyncDisposer`
3712+
3713+
<!-- YAML
3714+
added: REPLACEME
3715+
-->
3716+
3717+
A convenience wrapper on an async dispose function.
3718+
3719+
#### `asyncDisposer[Symbol.asyncDispose]()`
3720+
3721+
Invokes the function specified in `util.asyncDisposer(disposeFn)`.
3722+
3723+
Multiple invocations on this method only result in a single
3724+
invocation of the `disposeFn`.
3725+
3726+
### Class: `util.Disposer`
3727+
3728+
<!-- YAML
3729+
added: REPLACEME
3730+
-->
3731+
3732+
A convenience wrapper on a dispose function.
3733+
3734+
#### `disposer[Symbol.dispose]()`
3735+
3736+
Invokes the function specified in `util.disposer(disposeFn)`.
3737+
3738+
Multiple invocations on this method only result in a single
3739+
invocation of the `disposeFn`.
3740+
36583741
## Deprecated APIs
36593742
36603743
The following APIs are deprecated and should no longer be used. Existing

lib/internal/util/disposer.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use strict';
2+
3+
const {
4+
PromiseWithResolvers,
5+
SymbolAsyncDispose,
6+
SymbolDispose,
7+
} = primordials;
8+
const {
9+
validateFunction,
10+
} = require('internal/validators');
11+
12+
class Disposer {
13+
#disposed = false;
14+
#disposeFn;
15+
constructor(disposeFn) {
16+
validateFunction(disposeFn, 'disposeFn');
17+
this.#disposeFn = disposeFn;
18+
}
19+
20+
[SymbolDispose]() {
21+
if (this.#disposed) {
22+
return;
23+
}
24+
this.#disposed = true;
25+
this.#disposeFn();
26+
}
27+
}
28+
29+
class AsyncDisposer {
30+
/**
31+
* @type {PromiseWithResolvers<void>}
32+
*/
33+
#disposeDeferred;
34+
#disposeFn;
35+
constructor(disposeFn) {
36+
validateFunction(disposeFn, 'disposeFn');
37+
this.#disposeFn = disposeFn;
38+
}
39+
40+
[SymbolAsyncDispose]() {
41+
if (this.#disposeDeferred === undefined) {
42+
this.#disposeDeferred = PromiseWithResolvers();
43+
try {
44+
const ret = this.#disposeFn();
45+
this.#disposeDeferred.resolve(ret);
46+
} catch (err) {
47+
this.#disposeDeferred.reject(err);
48+
}
49+
}
50+
return this.#disposeDeferred.promise;
51+
}
52+
}
53+
54+
function disposer(disposeFn) {
55+
return new Disposer(disposeFn);
56+
}
57+
58+
function asyncDisposer(disposeFn) {
59+
return new AsyncDisposer(disposeFn);
60+
}
61+
62+
module.exports = {
63+
disposer,
64+
asyncDisposer,
65+
};

lib/util.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,9 @@ defineLazyProperties(
491491
'internal/util/diff',
492492
['diff'],
493493
);
494+
495+
defineLazyProperties(
496+
module.exports,
497+
'internal/util/disposer',
498+
['disposer', 'asyncDisposer', 'Disposer', 'AsyncDisposer'],
499+
);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use strict';
2+
3+
// This test checks that the semantics of `util.asyncDisposer` are as described in
4+
// the API docs
5+
6+
const common = require('../common');
7+
const assert = require('node:assert');
8+
const { asyncDisposer } = require('node:util');
9+
const test = require('node:test');
10+
11+
test('util.asyncDisposer should throw on non-function first parameter', () => {
12+
const invalidDisposers = [
13+
null,
14+
undefined,
15+
42,
16+
'string',
17+
{},
18+
[],
19+
Symbol('symbol'),
20+
];
21+
for (const invalidDisposer of invalidDisposers) {
22+
assert.throws(() => {
23+
asyncDisposer(invalidDisposer);
24+
}, {
25+
code: 'ERR_INVALID_ARG_TYPE',
26+
name: 'TypeError',
27+
});
28+
}
29+
});
30+
31+
test('util.asyncDisposer should create a AsyncDisposable object', async () => {
32+
const disposeFn = common.mustCall();
33+
const disposable = asyncDisposer(disposeFn);
34+
assert.strictEqual(typeof disposable, 'object');
35+
assert.strictEqual(disposable[Symbol.dispose], undefined);
36+
assert.strictEqual(typeof disposable[Symbol.asyncDispose], 'function');
37+
38+
// Multiple calls to asyncDispose should not throw and only invoke the function once.
39+
const p1 = disposable[Symbol.asyncDispose]();
40+
const p2 = disposable[Symbol.asyncDispose]();
41+
assert.strictEqual(p1, p2);
42+
await p1;
43+
});
44+
45+
test('AsyncDisposer[Symbol.asyncDispose] must be invoked with an AsyncDisposer', () => {
46+
const disposeFn = common.mustNotCall();
47+
const disposable = asyncDisposer(disposeFn);
48+
const asyncDispose = disposable[Symbol.asyncDispose];
49+
assert.throws(() => {
50+
asyncDispose.call({}); // Call with a non-AsyncDisposer object
51+
}, TypeError);
52+
});
53+
54+
test('AsyncDisposer[Symbol.asyncDispose] should reject if the disposerFn throws sync', async () => {
55+
const disposeFn = common.mustCall(() => {
56+
throw new Error('Disposer error');
57+
});
58+
const disposable = asyncDisposer(disposeFn);
59+
const promise = disposable[Symbol.asyncDispose]();
60+
61+
await assert.rejects(promise, {
62+
name: 'Error',
63+
message: 'Disposer error',
64+
});
65+
});
66+
67+
test('Disposer[Symbol.asyncDispose] should reject if the disposerFn rejects', async () => {
68+
const disposeFn = common.mustCall(() => {
69+
return Promise.reject(new Error('Disposer error'));
70+
});
71+
const disposable = asyncDisposer(disposeFn);
72+
const promise = disposable[Symbol.asyncDispose]();
73+
74+
await assert.rejects(promise, {
75+
name: 'Error',
76+
message: 'Disposer error',
77+
});
78+
});

test/parallel/test-util-disposer.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use strict';
2+
3+
// This test checks that the semantics of `util.disposer` are as described in
4+
// the API docs
5+
6+
const common = require('../common');
7+
const assert = require('node:assert');
8+
const { disposer } = require('node:util');
9+
const test = require('node:test');
10+
11+
test('util.disposer should throw on non-function first parameter', () => {
12+
const invalidDisposers = [
13+
null,
14+
undefined,
15+
42,
16+
'string',
17+
{},
18+
[],
19+
Symbol('symbol'),
20+
];
21+
for (const invalidDisposer of invalidDisposers) {
22+
assert.throws(() => {
23+
disposer(invalidDisposer);
24+
}, {
25+
code: 'ERR_INVALID_ARG_TYPE',
26+
name: 'TypeError',
27+
});
28+
}
29+
});
30+
31+
test('util.disposer should create a Disposable object', () => {
32+
const disposeFn = common.mustCall();
33+
const disposable = disposer(disposeFn);
34+
assert.strictEqual(typeof disposable, 'object');
35+
assert.strictEqual(typeof disposable[Symbol.dispose], 'function');
36+
assert.strictEqual(disposable[Symbol.asyncDispose], undefined);
37+
disposable[Symbol.dispose]();
38+
// Multiple calls to dispose should not throw and only invoke the function once.
39+
disposable[Symbol.dispose]();
40+
});
41+
42+
test('Disposer[Symbol.dispose] must be invoked with an Disposer', () => {
43+
const disposeFn = common.mustNotCall();
44+
const disposable = disposer(disposeFn);
45+
const dispose = disposable[Symbol.dispose];
46+
assert.throws(() => {
47+
dispose.call({}); // Call with a non-Disposer object
48+
}, TypeError);
49+
});
50+
51+
test('Disposer[Symbol.dispose] should throw if the disposerFn throws', () => {
52+
const disposeFn = common.mustCall(() => {
53+
throw new Error('Disposer error');
54+
});
55+
const disposable = disposer(disposeFn);
56+
assert.throws(() => {
57+
disposable[Symbol.dispose]();
58+
}, {
59+
name: 'Error',
60+
message: 'Disposer error',
61+
});
62+
63+
// Multiple calls to dispose should not throw and only invoke the function once.
64+
disposable[Symbol.dispose]();
65+
});

tools/doc/type-parser.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ const customTypesMap = {
6767
'AsyncHook': 'async_hooks.html#async_hookscreatehookcallbacks',
6868
'AsyncResource': 'async_hooks.html#class-asyncresource',
6969

70+
'AsyncDisposer': 'util.html#class-utilasyncdisposer',
71+
'Disposer': 'util.html#class-utildisposer',
72+
7073
'brotli options': 'zlib.html#class-brotlioptions',
7174

7275
'Buffer': 'buffer.html#class-buffer',

0 commit comments

Comments
 (0)