diff --git a/packages/util/src/freeable.ts b/packages/util/src/freeable.ts new file mode 100644 index 00000000000..e5d0b5ae274 --- /dev/null +++ b/packages/util/src/freeable.ts @@ -0,0 +1,64 @@ +import { Freeable } from './types'; + +/** + * A scope to ease the management of objects that require manual resource management. + * + */ +export class ManagedFreeableScope { + #scopeStack: Freeable[] = []; + #disposed = false; + + /** + * Objects passed to this method will then be managed by the instance. + * + * @param freeable An object with a free function, or undefined. This makes it suitable for wrapping functions that + * may or may not return a value, to minimise the implementation logic. + * @returns The freeable object passed in, which can be undefined. + */ + public manage(freeable: T): T { + if (freeable === undefined) return freeable; + if (this.#disposed) throw new Error('This scope is already disposed.'); + this.#scopeStack.push(freeable); + return freeable; + } + + /** + * Once the freeable objects being managed are no longer being accessed, call this method. + */ + public dispose(): void { + if (this.#disposed) return; + for (const resource of this.#scopeStack) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((resource as any)?.ptr === 0 || !resource?.free) { + continue; + } + + resource?.free(); + } + this.#disposed = true; + } +} + +class AutoFree { + #scope: ManagedFreeableScope; + readonly #callback: (scope: ManagedFreeableScope) => TReturn; + + constructor(cb: (scope: ManagedFreeableScope) => TReturn) { + this.#callback = cb; + this.#scope = new ManagedFreeableScope(); + } + + public execute() { + try { + return this.#callback(this.#scope); + } finally { + this.#scope.dispose(); + } + } +} + +/** + * A wrapper function to setup and dispose of a ManagedFreeableScope at the end of the callback execution. + */ +export const usingAutoFree = (cb: (scope: ManagedFreeableScope) => TReturn) => + new AutoFree(cb).execute(); diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 830142c9a4b..b215610b0c8 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -1,4 +1,5 @@ export * from './equals'; +export * from './freeable'; export * from './types'; export * from './BigIntMath'; export * from './hexString'; diff --git a/packages/util/src/types.ts b/packages/util/src/types.ts index 08bb59cb30d..3477b964964 100644 --- a/packages/util/src/types.ts +++ b/packages/util/src/types.ts @@ -15,3 +15,7 @@ export type DeepPartial = T extends O | Primitive : { [P in keyof T]?: DeepPartial; }; + +export interface Freeable { + free: () => void; +} diff --git a/packages/util/test/freeable.test.ts b/packages/util/test/freeable.test.ts new file mode 100644 index 00000000000..0b2670566aa --- /dev/null +++ b/packages/util/test/freeable.test.ts @@ -0,0 +1,59 @@ +import { Freeable, ManagedFreeableScope, usingAutoFree } from '../src'; + +class FreeableEntity implements Freeable { + constructor(public id: number) {} + public getId(): number { + return this.id; + } + public free() { + void 0; + } +} + +describe('freeable', () => { + describe('ManagedFreeableScope', () => { + it('manage returns undefined if argument passed is undefined', () => { + const scope = new ManagedFreeableScope(); + const freeable = undefined; + const one = scope.manage(freeable); + expect(one).toBeUndefined(); + }); + }); + describe('usingAutoFree', () => { + it('calls the object free method after executing callback', () => { + const entity = new FreeableEntity(1); + const spy = jest.spyOn(entity, 'free'); + usingAutoFree((scope) => { + const one = scope.manage(entity); + expect(one.getId()).toBe(1); + }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('can return a value', () => { + const entity = new FreeableEntity(1); + const spy = jest.spyOn(entity, 'free'); + const id = usingAutoFree((scope) => { + const one = scope.manage(entity); + return one.getId(); + }); + expect(id).toBe(1); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('can handle multiple objects', () => { + const firstEntity = new FreeableEntity(1); + const secondEntity = new FreeableEntity(2); + const firstSpy = jest.spyOn(firstEntity, 'free'); + const secondSpy = jest.spyOn(secondEntity, 'free'); + usingAutoFree((scope) => { + const one = scope.manage(firstEntity); + const two = scope.manage(secondEntity); + expect(one.getId()).toBe(1); + expect(two.getId()).toBe(2); + }); + expect(firstSpy).toHaveBeenCalledTimes(1); + expect(secondSpy).toHaveBeenCalledTimes(1); + }); + }); +});