From c1f1080df1485b0d7c16b15e85c05b1569dd5aa3 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Wed, 4 Jun 2025 13:21:45 +0200 Subject: [PATCH] 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. --- doc/api/util.md | 83 ++++++++++++++++++++++++ lib/internal/util/disposer.js | 73 +++++++++++++++++++++ lib/util.js | 6 ++ test/parallel/test-util-asyncdisposer.js | 81 +++++++++++++++++++++++ test/parallel/test-util-disposer.js | 68 +++++++++++++++++++ tools/doc/type-parser.mjs | 3 + 6 files changed, 314 insertions(+) create mode 100644 lib/internal/util/disposer.js create mode 100644 test/parallel/test-util-asyncdisposer.js create mode 100644 test/parallel/test-util-disposer.js diff --git a/doc/api/util.md b/doc/api/util.md index 9056067bede058..041225a3516cc0 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -3655,6 +3655,89 @@ Returns `true` if the value is a built-in {WeakSet} instance. util.types.isWeakSet(new WeakSet()); // Returns true ``` +## Disposer APIs + +> Stability: 1 - Experimental + +### `util.asyncDisposer(onDispose)` + + + +* `onDispose` {Function} A dispose function returning a promise +* Returns: {AsyncDisposer} + +Create a convenient wrapper on the given async function that can be used +with `using` declaration. + +If an error is thrown in the function, instead of returning a promise, +the error will be wrapped in a rejected promise. + +```mjs +{ + await using _ = util.disposer(async function disposer() { + // Performing async disposing actions... + }); + + // Doing some works... + +} // When this scope exits, the disposer function is invoked and awaited. +``` + +### `util.disposer(onDispose)` + + + +* `onDispose` {Function} A dispose function +* Returns: {Disposer} + +Create a convenient wrapper on the given function that can be used with +`using` declaration. + +```js +{ + using _ = util.disposer(function disposer() { + // Performing disposing actions... + }); + + // Doing some works... + +} // When this scope exits, the disposer function is invoked. +``` + +### Class: `util.AsyncDisposer` + + + +A convenience wrapper on an async dispose function. + +#### `asyncDisposer[Symbol.asyncDispose]()` + +Invokes the function specified in `util.asyncDisposer(onDispose)`. + +Multiple invocations on this method only result in a single +invocation of the `onDispose` function. + +### Class: `util.Disposer` + + + +A convenience wrapper on a dispose function. + +#### `disposer[Symbol.dispose]()` + +Invokes the function specified in `util.disposer(onDispose)`. + +Multiple invocations on this method only result in a single +invocation of the `onDispose` function. + ## Deprecated APIs The following APIs are deprecated and should no longer be used. Existing diff --git a/lib/internal/util/disposer.js b/lib/internal/util/disposer.js new file mode 100644 index 00000000000000..c039f945c80b42 --- /dev/null +++ b/lib/internal/util/disposer.js @@ -0,0 +1,73 @@ +'use strict'; + +const { + PromiseWithResolvers, + SymbolAsyncDispose, + SymbolDispose, +} = primordials; +const { + validateFunction, +} = require('internal/validators'); + +class Disposer { + #disposed = false; + #onDispose; + constructor(onDispose) { + validateFunction(onDispose, 'disposeFn'); + this.#onDispose = onDispose; + } + + dispose() { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#onDispose(); + } + + [SymbolDispose]() { + this.dispose(); + } +} + +class AsyncDisposer { + /** + * @type {PromiseWithResolvers} + */ + #disposeDeferred; + #onDispose; + constructor(onDispose) { + validateFunction(onDispose, 'disposeFn'); + this.#onDispose = onDispose; + } + + dispose() { + if (this.#disposeDeferred === undefined) { + this.#disposeDeferred = PromiseWithResolvers(); + try { + const ret = this.#onDispose(); + this.#disposeDeferred.resolve(ret); + } catch (err) { + this.#disposeDeferred.reject(err); + } + } + return this.#disposeDeferred.promise; + } + + [SymbolAsyncDispose]() { + return this.dispose(); + } +} + +function disposer(disposeFn) { + return new Disposer(disposeFn); +} + +function asyncDisposer(disposeFn) { + return new AsyncDisposer(disposeFn); +} + +module.exports = { + disposer, + asyncDisposer, +}; diff --git a/lib/util.js b/lib/util.js index a422daa212b855..d132ac054bb71e 100644 --- a/lib/util.js +++ b/lib/util.js @@ -491,3 +491,9 @@ defineLazyProperties( 'internal/util/diff', ['diff'], ); + +defineLazyProperties( + module.exports, + 'internal/util/disposer', + ['disposer', 'asyncDisposer', 'Disposer', 'AsyncDisposer'], +); diff --git a/test/parallel/test-util-asyncdisposer.js b/test/parallel/test-util-asyncdisposer.js new file mode 100644 index 00000000000000..60dd79a6a58a0e --- /dev/null +++ b/test/parallel/test-util-asyncdisposer.js @@ -0,0 +1,81 @@ +'use strict'; + +// This test checks that the semantics of `util.asyncDisposer` are as described in +// the API docs + +const common = require('../common'); +const assert = require('node:assert'); +const { asyncDisposer } = require('node:util'); +const test = require('node:test'); + +test('util.asyncDisposer should throw on non-function first parameter', () => { + const invalidDisposers = [ + null, + undefined, + 42, + 'string', + {}, + [], + Symbol('symbol'), + ]; + for (const invalidDisposer of invalidDisposers) { + assert.throws(() => { + asyncDisposer(invalidDisposer); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + } +}); + +test('util.asyncDisposer should create a AsyncDisposable object', async () => { + const disposeFn = common.mustCall(); + const disposable = asyncDisposer(disposeFn); + assert.strictEqual(typeof disposable, 'object'); + assert.strictEqual(disposable[Symbol.dispose], undefined); + assert.strictEqual(typeof disposable[Symbol.asyncDispose], 'function'); + + // Multiple calls to asyncDispose should not throw and only invoke the function once. + const p1 = disposable[Symbol.asyncDispose](); + const p2 = disposable[Symbol.asyncDispose](); + assert.strictEqual(p1, p2); + await p1; +}); + +test('AsyncDisposer[Symbol.asyncDispose] must be invoked with an AsyncDisposer', () => { + const disposeFn = common.mustNotCall(); + const disposable = asyncDisposer(disposeFn); + assert.throws(() => { + disposable[Symbol.asyncDispose].call({}); // Call with a non-AsyncDisposer object + }, TypeError); + + assert.throws(() => { + disposable.dispose.call({}); // Call with a non-AsyncDisposer object + }, TypeError); +}); + +test('AsyncDisposer[Symbol.asyncDispose] should reject if the disposerFn throws sync', async () => { + const disposeFn = common.mustCall(() => { + throw new Error('Disposer error'); + }); + const disposable = asyncDisposer(disposeFn); + const promise = disposable[Symbol.asyncDispose](); + + await assert.rejects(promise, { + name: 'Error', + message: 'Disposer error', + }); +}); + +test('Disposer[Symbol.asyncDispose] should reject if the disposerFn rejects', async () => { + const disposeFn = common.mustCall(() => { + return Promise.reject(new Error('Disposer error')); + }); + const disposable = asyncDisposer(disposeFn); + const promise = disposable[Symbol.asyncDispose](); + + await assert.rejects(promise, { + name: 'Error', + message: 'Disposer error', + }); +}); diff --git a/test/parallel/test-util-disposer.js b/test/parallel/test-util-disposer.js new file mode 100644 index 00000000000000..4f5daed2a21c07 --- /dev/null +++ b/test/parallel/test-util-disposer.js @@ -0,0 +1,68 @@ +'use strict'; + +// This test checks that the semantics of `util.disposer` are as described in +// the API docs + +const common = require('../common'); +const assert = require('node:assert'); +const { disposer } = require('node:util'); +const test = require('node:test'); + +test('util.disposer should throw on non-function first parameter', () => { + const invalidDisposers = [ + null, + undefined, + 42, + 'string', + {}, + [], + Symbol('symbol'), + ]; + for (const invalidDisposer of invalidDisposers) { + assert.throws(() => { + disposer(invalidDisposer); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + } +}); + +test('util.disposer should create a Disposable object', () => { + const disposeFn = common.mustCall(); + const disposable = disposer(disposeFn); + assert.strictEqual(typeof disposable, 'object'); + assert.strictEqual(typeof disposable[Symbol.dispose], 'function'); + assert.strictEqual(disposable[Symbol.asyncDispose], undefined); + disposable[Symbol.dispose](); + // Multiple calls to dispose should not throw and only invoke the function once. + disposable[Symbol.dispose](); +}); + +test('Disposer[Symbol.dispose] must be invoked with an Disposer', () => { + const disposeFn = common.mustNotCall(); + const disposable = disposer(disposeFn); + assert.throws(() => { + disposable[Symbol.dispose].call({}); // Call with a non-Disposer object + }, TypeError); + + assert.throws(() => { + disposable.dispose.call({}); // Call with a non-Disposer object + }, TypeError); +}); + +test('Disposer[Symbol.dispose] should throw if the disposerFn throws', () => { + const disposeFn = common.mustCall(() => { + throw new Error('Disposer error'); + }); + const disposable = disposer(disposeFn); + assert.throws(() => { + disposable[Symbol.dispose](); + }, { + name: 'Error', + message: 'Disposer error', + }); + + // Multiple calls to dispose should not throw and only invoke the function once. + disposable[Symbol.dispose](); +}); diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index adf320af6e1989..1a683e43766ef0 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -67,6 +67,9 @@ const customTypesMap = { 'AsyncHook': 'async_hooks.html#async_hookscreatehookcallbacks', 'AsyncResource': 'async_hooks.html#class-asyncresource', + 'AsyncDisposer': 'util.html#class-utilasyncdisposer', + 'Disposer': 'util.html#class-utildisposer', + 'brotli options': 'zlib.html#class-brotlioptions', 'Buffer': 'buffer.html#class-buffer',