import { deferred } from 'promise-assist'; import { Disposables } from './create-disposables.js'; import type { IDisposable } from './types.js'; const DELAY_DISPOSAL = 'unreleased disposal guard, an async guarded task is still running'; const DISPOSAL_GUARD_DEFAULTS = { name: 'unsafe execution: instance was disposed', timeout: 5_000, usedWhileDisposing: false, }; type OPTIONS = Partial; type GUARDED_FN_ASYNC = () => Promise; type GUARDED_FN_SYNC = () => T; type GUARDED_FN = GUARDED_FN_SYNC | GUARDED_FN_ASYNC; /** * Adds dispose-safe methods to Disposables: * * - setInterval/setTimeout * - guard * @example * ```ts * export class MyDisposable implements IDisposable { * private disposables = new SafeDisposable(MyDisposable.name) * dispose: () => Promise; * * constructor() { * this.disposables.add('log', () => console.log('disposed')); * this.disposables.setTimeout(() => console.log('will be canceled upon disposal'), 1000); * this.dispose = () => this.disposables.dispose() * } * * async doSomething() { * // will throw if disposed, delays disposal until done is called * return await this.disposables.guard(async () =>{ * // do something * return await somePromise // if dispose is called while the code awaits, new guards will throw, but actual disposal will not begin * }) * // disposal may begin * } * } * ``` */ export class SafeDisposable extends Disposables implements IDisposable { private _isDisposed = false; private _isDisposing?: Promise; private timeouts = new Set>(); private intervals = new Set>(); constructor(name: string) { super(name); this.registerGroup(DELAY_DISPOSAL, { before: 'default' }); this.add('dispose timeouts and intervals', () => { this.timeouts.forEach((t) => clearTimeout(t)); this.intervals.forEach((i) => clearInterval(i)); }); } /** * Starts instance disposal: * * **phase 1: disposing** * - isDisposed === true * - guard() // will throw * - guard({usedWhileDisposing:true}) // will not throw (for methods that are used in the disposal process) * - all guards are awaited * - disposable.dispose is awaited * * **phase 2: disposed done** * - guard({usedWhileDisposing:true}) // will throw */ override async dispose() { if (this.isDisposed()) { return this._isDisposing; } else { this._isDisposing = super.dispose(); await this._isDisposing; this._isDisposed = true; this._isDisposing = undefined; } } /** * returns true if the disposal process started */ isDisposed = () => !!(this._isDisposed || this._isDisposing); /** * After disposal starts, it's necessary to avoid executing some code. `guard` is used for those cases. * * for example: after fileRemover.dispose(), fileRemover.remove() should throw. * * `guard` will: * * - throws if disposal started/finished * - delays disposal actual until the current flow is done * * @example * ```ts * // this will throw if disposed * this.guard(()=> { * // do something * // if dispose is called while the code executes, * // new guards will throw, but actual disposal will not begin * }, {timeout: 1000, name:'something'}); * // disposal may begin * ``` */ guard(fn: GUARDED_FN_ASYNC, options?: OPTIONS): Promise; guard(fn: GUARDED_FN_SYNC, options?: OPTIONS): T; guard<_T>(options?: OPTIONS): void; // guard(options?: OPTIONS): { [Symbol.dispose]: () => void }; // @internal guard(fnOrOptions?: OPTIONS | GUARDED_FN, options?: OPTIONS) { const { fn, options: { name, timeout, usedWhileDisposing }, } = extractArgs(fnOrOptions, options); if (this.isDisposed() && !(usedWhileDisposing && this._isDisposing)) { throw new Error('Instance was disposed'); } const { promise: canDispose, resolve: done } = deferred(); const removeGuard = this.add({ group: DELAY_DISPOSAL, name, timeout, dispose: () => canDispose, }); canDispose.then(removeGuard, removeGuard); return executeCode(fn, done); /** * Support for the "using" keyword * uncomment when supported in browsers */ // || { [Symbol.dispose]: done }; } /** * a disposal safe setTimeout * checks disposal before execution and clears the timeout when the instance is disposed */ setTimeout(fn: () => void, timeout: number): ReturnType { this.guard(); const handle = setTimeout(() => { this.timeouts.delete(handle); if (!this.isDisposed()) { fn(); } }, timeout); this.timeouts.add(handle); return handle; } /** * a disposal safe setInterval * checks disposal before execution and clears the interval when the instance is disposed */ setInterval(fn: () => void, interval: number): ReturnType { this.guard(); const handle = setInterval(() => { if (!this.isDisposed()) { fn(); } }, interval); this.intervals.add(handle); return handle; } /** * Support for the "using" keyword * uncomment when supported in browsers */ // [Symbol.asyncDispose] = () => this.dispose(); } function extractArgs(fnOrOptions?: OPTIONS | GUARDED_FN, options?: OPTIONS) { if (fnOrOptions instanceof Function) { return { fn: fnOrOptions, options: { ...DISPOSAL_GUARD_DEFAULTS, ...(options ?? {}), }, }; } else { return { fn: null, options: { ...DISPOSAL_GUARD_DEFAULTS, ...(fnOrOptions ?? {}), }, }; } } function executeCode(fn: GUARDED_FN | null, done: () => void) { let result: T | Promise; if (fn) { try { result = fn(); if (!(result instanceof Promise)) { done(); return result; } } catch (e) { done(); throw e; } return result.finally(done); } /** * Support for the "using" keyword * uncomment when supported in browsers */ // return // TODO remove when "using" is supported in browsers return done(); }