diff --git a/src/common/utils/asyncUtils.ts b/src/common/utils/asyncUtils.ts index 02bb265c..1fabf34d 100644 --- a/src/common/utils/asyncUtils.ts +++ b/src/common/utils/asyncUtils.ts @@ -1,3 +1,137 @@ -export async function timeout(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); +import { CancellationToken, CancellationTokenSource } from 'vscode'; + +/** + * A promise that can be cancelled using the `.cancel()` method. + */ +export interface CancelablePromise extends Promise { + cancel(): void; +} + +/** + * Error thrown when a promise is cancelled. + */ +export class CancellationError extends Error { + constructor() { + super('Cancelled'); + this.name = 'CancellationError'; + } +} + +/** + * Returns a promise that can be cancelled using the provided cancellation token. + * + * @remarks When cancellation is requested, the promise will be rejected with a {@link CancellationError}. + * + * @param callback A function that accepts a cancellation token and returns a promise + * @returns A promise that can be cancelled + */ +export function createCancelablePromise(callback: (token: CancellationToken) => Promise): CancelablePromise { + const source = new CancellationTokenSource(); + + const thenable = callback(source.token); + const promise = new Promise((resolve, reject) => { + const subscription = source.token.onCancellationRequested(() => { + subscription.dispose(); + reject(new CancellationError()); + }); + Promise.resolve(thenable).then( + (value) => { + subscription.dispose(); + source.dispose(); + resolve(value); + }, + (err) => { + subscription.dispose(); + source.dispose(); + reject(err); + }, + ); + }); + + return new (class { + cancel() { + source.cancel(); + source.dispose(); + } + then( + resolve?: ((value: T) => TResult1 | Promise) | undefined | null, + reject?: ((reason: unknown) => TResult2 | Promise) | undefined | null, + ): Promise { + return promise.then(resolve, reject); + } + catch( + reject?: ((reason: unknown) => TResult | Promise) | undefined | null, + ): Promise { + return this.then(undefined, reject); + } + finally(onfinally?: (() => void) | undefined | null): Promise { + return promise.finally(onfinally); + } + })() as CancelablePromise; +} + +/** + * Returns a promise that resolves with `undefined` as soon as the passed token is cancelled. + * @see {@link raceCancellationError} + */ +export function raceCancellation(promise: Promise, token: CancellationToken): Promise; + +/** + * Returns a promise that resolves with `defaultValue` as soon as the passed token is cancelled. + * @see {@link raceCancellationError} + */ +export function raceCancellation(promise: Promise, token: CancellationToken, defaultValue: T): Promise; + +export function raceCancellation(promise: Promise, token: CancellationToken, defaultValue?: T): Promise { + return new Promise((resolve, reject) => { + const ref = token.onCancellationRequested(() => { + ref.dispose(); + resolve(defaultValue); + }); + promise.then(resolve, reject).finally(() => ref.dispose()); + }); +} + +/** + * Returns a promise that rejects with a {@link CancellationError} as soon as the passed token is cancelled. + * @see {@link raceCancellation} + */ +export function raceCancellationError(promise: Promise, token: CancellationToken): Promise { + return new Promise((resolve, reject) => { + const ref = token.onCancellationRequested(() => { + ref.dispose(); + reject(new CancellationError()); + }); + promise.then(resolve, reject).finally(() => ref.dispose()); + }); +} + +/** + * Creates a timeout promise that resolves after the specified number of milliseconds. + * Can be cancelled using the returned CancelablePromise's cancel() method. + */ +export function timeout(millis: number): CancelablePromise; + +/** + * Creates a timeout promise that resolves after the specified number of milliseconds, + * or rejects with CancellationError if the token is cancelled. + */ +export function timeout(millis: number, token: CancellationToken): Promise; + +export function timeout(millis: number, token?: CancellationToken): CancelablePromise | Promise { + if (!token) { + return createCancelablePromise((token) => timeout(millis, token)); + } + + return new Promise((resolve, reject) => { + const handle = setTimeout(() => { + disposable.dispose(); + resolve(); + }, millis); + const disposable = token.onCancellationRequested(() => { + clearTimeout(handle); + disposable.dispose(); + reject(new CancellationError()); + }); + }); } diff --git a/src/test/common/utils/asyncUtils.unit.test.ts b/src/test/common/utils/asyncUtils.unit.test.ts new file mode 100644 index 00000000..5d41bc35 --- /dev/null +++ b/src/test/common/utils/asyncUtils.unit.test.ts @@ -0,0 +1,273 @@ +import assert from 'assert'; +import { CancellationTokenSource } from 'vscode'; +import { + timeout, + createCancelablePromise, + raceCancellation, + raceCancellationError, + CancellationError, +} from '../../../common/utils/asyncUtils'; + +suite('Async Utils Tests', () => { + suite('timeout', () => { + test('should resolve after specified milliseconds', async () => { + const start = Date.now(); + await timeout(50); + const elapsed = Date.now() - start; + assert(elapsed >= 45, `Expected at least 45ms, got ${elapsed}ms`); + }); + + test('should return a CancelablePromise when called without token', () => { + const promise = timeout(100); + assert(typeof promise.cancel === 'function', 'Should have cancel method'); + promise.cancel(); + }); + + test('should be cancellable via cancel() method', async () => { + const promise = timeout(100); + promise.cancel(); + + await assert.rejects( + async () => promise, + (err: Error) => err instanceof CancellationError, + 'Should reject with CancellationError', + ); + }); + + test('should reject with CancellationError when token is cancelled', async () => { + const source = new CancellationTokenSource(); + const promise = timeout(100, source.token); + + // Cancel immediately + source.cancel(); + + await assert.rejects( + async () => promise, + (err: Error) => err instanceof CancellationError, + 'Should reject with CancellationError', + ); + }); + + test('should resolve normally when token is not cancelled', async () => { + const source = new CancellationTokenSource(); + const start = Date.now(); + await timeout(50, source.token); + const elapsed = Date.now() - start; + assert(elapsed >= 45, `Expected at least 45ms, got ${elapsed}ms`); + }); + + test('should not resolve when cancelled before timeout', async () => { + const source = new CancellationTokenSource(); + const promise = timeout(1000, source.token); + + // Cancel after a short delay + setTimeout(() => source.cancel(), 10); + + const start = Date.now(); + await assert.rejects( + async () => promise, + (err: Error) => err instanceof CancellationError, + 'Should reject with CancellationError', + ); + const elapsed = Date.now() - start; + assert(elapsed < 100, `Should cancel quickly, took ${elapsed}ms`); + }); + }); + + suite('createCancelablePromise', () => { + test('should create a promise that can be cancelled', async () => { + const promise = createCancelablePromise(async () => { + await timeout(100); + return 'completed'; + }); + + promise.cancel(); + + await assert.rejects( + async () => promise, + (err: Error) => err instanceof CancellationError, + 'Should reject with CancellationError', + ); + }); + + test('should resolve normally when not cancelled', async () => { + const promise = createCancelablePromise(async () => { + await timeout(10); + return 'completed'; + }); + + const result = await promise; + assert.strictEqual(result, 'completed', 'Should resolve with expected value'); + }); + + test('should pass cancellation token to callback', async () => { + let tokenReceived = false; + const promise = createCancelablePromise(async (token) => { + tokenReceived = token !== undefined; + return 'done'; + }); + + await promise; + assert(tokenReceived, 'Token should be passed to callback'); + }); + + test('should reject when callback throws', async () => { + const promise = createCancelablePromise(async () => { + throw new Error('test error'); + }); + + await assert.rejects( + async () => promise, + (err: Error) => err.message === 'test error', + 'Should reject with the thrown error', + ); + }); + + test('should support promise chaining with then', async () => { + const promise = createCancelablePromise(async () => 42); + const result = await promise.then((value) => value * 2); + assert.strictEqual(result, 84, 'Should support then chaining'); + }); + + test('should support promise chaining with catch', async () => { + const promise = createCancelablePromise(async () => { + throw new Error('test'); + }); + const result = await promise.catch(() => 'caught'); + assert.strictEqual(result, 'caught', 'Should support catch chaining'); + }); + + test('should support promise chaining with finally', async () => { + let finallyCalled = false; + const promise = createCancelablePromise(async () => 42); + await promise.finally(() => { + finallyCalled = true; + }); + assert(finallyCalled, 'Should support finally chaining'); + }); + }); + + suite('raceCancellation', () => { + test('should resolve with promise value when not cancelled', async () => { + const source = new CancellationTokenSource(); + const promise = Promise.resolve('value'); + const result = await raceCancellation(promise, source.token); + assert.strictEqual(result, 'value', 'Should resolve with promise value'); + }); + + test('should resolve with undefined when cancelled', async () => { + const source = new CancellationTokenSource(); + const promise = new Promise((resolve) => setTimeout(() => resolve('value'), 100)); + + const racePromise = raceCancellation(promise, source.token); + // Cancel after a microtask to allow event subscription + await Promise.resolve(); + source.cancel(); + + const result = await racePromise; + assert.strictEqual(result, undefined, 'Should resolve with undefined when cancelled'); + }); + + test('should resolve with default value when cancelled', async () => { + const source = new CancellationTokenSource(); + const promise = new Promise((resolve) => setTimeout(() => resolve('value'), 100)); + + const racePromise = raceCancellation(promise, source.token, 'default'); + // Cancel after a microtask to allow event subscription + await Promise.resolve(); + source.cancel(); + + const result = await racePromise; + assert.strictEqual(result, 'default', 'Should resolve with default value when cancelled'); + }); + + test('should reject if promise rejects', async () => { + const source = new CancellationTokenSource(); + const promise = Promise.reject(new Error('test error')); + + await assert.rejects( + async () => raceCancellation(promise, source.token), + (err: Error) => err.message === 'test error', + 'Should reject with promise rejection', + ); + }); + + test('should race between promise and cancellation', async () => { + const source = new CancellationTokenSource(); + const promise = new Promise((resolve) => setTimeout(() => resolve('slow'), 100)); + + // Cancel after a short delay + setTimeout(() => source.cancel(), 10); + + const result = await raceCancellation(promise, source.token, 'cancelled'); + assert.strictEqual(result, 'cancelled', 'Should resolve with cancelled value'); + }); + }); + + suite('raceCancellationError', () => { + test('should resolve with promise value when not cancelled', async () => { + const source = new CancellationTokenSource(); + const promise = Promise.resolve('value'); + const result = await raceCancellationError(promise, source.token); + assert.strictEqual(result, 'value', 'Should resolve with promise value'); + }); + + test('should reject with CancellationError when cancelled', async () => { + const source = new CancellationTokenSource(); + const promise = new Promise((resolve) => setTimeout(() => resolve('value'), 100)); + + const racePromise = raceCancellationError(promise, source.token); + // Cancel after a microtask to allow event subscription + await Promise.resolve(); + source.cancel(); + + await assert.rejects( + async () => racePromise, + (err: Error) => err instanceof CancellationError, + 'Should reject with CancellationError', + ); + }); + + test('should reject if promise rejects', async () => { + const source = new CancellationTokenSource(); + const promise = Promise.reject(new Error('test error')); + + await assert.rejects( + async () => raceCancellationError(promise, source.token), + (err: Error) => err.message === 'test error', + 'Should reject with promise rejection', + ); + }); + + test('should race between promise and cancellation', async () => { + const source = new CancellationTokenSource(); + const promise = new Promise((resolve) => setTimeout(() => resolve('slow'), 100)); + + // Cancel after a short delay + setTimeout(() => source.cancel(), 10); + + await assert.rejects( + async () => raceCancellationError(promise, source.token), + (err: Error) => err instanceof CancellationError, + 'Should reject with CancellationError', + ); + }); + }); + + suite('CancellationError', () => { + test('should be instanceof Error', () => { + const error = new CancellationError(); + assert(error instanceof Error, 'Should be instanceof Error'); + }); + + test('should have correct message', () => { + const error = new CancellationError(); + assert.strictEqual(error.message, 'Cancelled', 'Should have "Cancelled" message'); + }); + + test('should have correct name', () => { + const error = new CancellationError(); + assert.strictEqual(error.name, 'CancellationError', 'Should have "CancellationError" name'); + }); + }); +});