diff --git a/packages/backend/src/misc/promiseUtils.ts b/packages/backend/src/misc/promiseUtils.ts new file mode 100644 index 0000000000..d3e754582c --- /dev/null +++ b/packages/backend/src/misc/promiseUtils.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { throwIfAborted } from '@/misc/throw-if-aborted.js'; +import { AbortedError } from '@/misc/errors/AbortedError.js'; + +/** + * Executes a task or promise, then runs a provided cleanup task. + * The resulting task resolves only when *both* steps are complete. + * One or both of the steps may throw, but the other will always run anyway. + * All errors are captured, aggregated, and re-thrown by the final promise. + * + * @param promiseOrCallback Promise or async callback to execute + * @param cleanup Cleanup callback to execute after execution completes or fails + */ +export async function withCleanup(promiseOrCallback: MaybeCallback>, cleanup: () => MaybePromise): Promise { + // Execute the task first + let executionResult: Result; + try { + const result = typeof(promiseOrCallback) === 'function' + ? await promiseOrCallback() + : await promiseOrCallback; + executionResult = { success: true, result }; + } catch (error) { + executionResult = { success: false, error }; + } + + // Run cleanup next, even if execution failed + let cleanupResult: Result; + try { + const result = await cleanup(); + cleanupResult = { success: true, result }; + } catch (error) { + cleanupResult = { success: false, error }; + } + + if (!executionResult.success) { + if (!cleanupResult.success) { + // Execution and cleanup failed + throw new AggregateError([executionResult.error, cleanupResult.error]); + } else { + // Execution failed, but cleanup succeeded + throw executionResult.error; + } + } + + // Execution succeeded, but cleanup failed + if (!cleanupResult.success) { + throw cleanupResult.error; + } + + // Execution and cleanup succeeded + return executionResult.result; +} + +/** + * Binds an AbortSignal to a Promise. + * The returned promise will resolve or reject with the result of the provided promise, unless the signal is aborted first. + * + * The promise must be provided as an async factory, which will be called to produce the actual task promise. + * This requirement is in place to ensure consistent behavior if the abortSignal is already aborted. + * Otherwise, the input promise may produce an UnhandledPromiseRejection error that crashes the app. + * @param factory Callback to start the promise + * @param abortSignal Signal to terminate the promise + */ // TODO accept a promise directly here +export async function withSignal(factory: () => Promise, abortSignal: AbortSignal): Promise { + // If already aborted, then don't do anything. + throwIfAborted(abortSignal); + + // Create a promise with controls. + const { promise, resolve, reject } = Promise.withResolvers(); + const abort = () => reject(new AbortedError(abortSignal)); + + // Bind the abort signal. + abortSignal.addEventListener('abort', abort); + promise + .finally(() => abortSignal.removeEventListener('abort', abort)) + .catch(() => null); // Make sure it's never an unhandled rejection! + + // Bind the task promise. + const taskPromise = factory(); + taskPromise + .then(result => resolve(result), err => reject(err)) + .catch(() => null); // Make sure it's never an unhandled rejection! + + return promise; +} + +type Result = + { success: true, result: T } | + { success: false, error: unknown }; + +type MaybeCallback = T | (() => T); +type MaybePromise = T | Promise;