UNPKG

cancellationtoken

Version:

A composable token for cancelling asynchronous operations.

352 lines (295 loc) 11.2 kB
import 'jest' import CancellationToken from './CancellationToken' /** * The number of iterations to run a loop that allocates memory that * should be released in each iteration. * @description This number is large enough that a leak will cause the test * to take a VERY long time due to constant GC (or crash from OOM). * It's also small enough for the test to pass quickly if there is no leak. */ const LEAK_LOOP_COUNT = 100000 describe('A cancellation token', () => { describe('that was created independently', () => { let cancel: (reason?: any) => void let token: CancellationToken const reason = {} beforeEach(() => { ;({cancel, token} = CancellationToken.create()) }) it('claims to be cancellable', () => { expect(token.canBeCancelled).toBe(true) }) it('should not be cancelled immediately after creation', () => { expect(token.isCancelled).toBe(false) }) it('should cancel correctly', () => { cancel(reason) expect(token.isCancelled).toBe(true) expect(token.reason).toBe(reason) }) it('should not throw an error or change the reason when cancelled multiple times', () => { cancel(reason) cancel({}) expect(token.reason).toBe(reason) }) it('should execute registered cancellation callbacks upon cancellation', (done) => { token.onCancelled((actualReason) => { expect(actualReason).toBe(reason) done() }) cancel(reason) }) it('should execute registered cancellation callbacks immediately if canceled', () => { cancel(reason) let cbInvoked = false const unregister = token.onCancelled((actualReason) => { expect(actualReason).toBe(reason) cbInvoked = true }) expect(cbInvoked).toBe(true) unregister() // no-op, but valid function }) it('should not execute unregistered cancellation callbacks upon cancellation', () => { const unregister = token.onCancelled((actualReason) => { fail('Unexpected callback of unregistered cancellation callback.') }) unregister() cancel(reason) }) it('should not throw an error when unregistering a cancellation callback after cancellation', () => { const unregister = token.onCancelled((actualReason) => {}) cancel(reason) unregister() }) it('should throw a CancelledError when throwIfCancelled is called and the token is cancelled', () => { cancel(reason) try { token.throwIfCancelled() fail('Expected CancellationToken.Cancelled to be thrown') } catch (err) { expect(err).toBeInstanceOf(CancellationToken.CancellationError) expect(err.reason).toBe(reason) } }) it('should not throw an error when throwIfCancelled is called and the token is not cancelled', () => { token.throwIfCancelled() // should not throw }) it('should throw an error when accessing the reason before the token is cancelled', () => { expect(() => token.reason).toThrow() }) it('can be raced against a promise and lose to fulfillment', async () => { const promise = new Promise<number>((resolve) => { setTimeout(() => resolve(5), 1) }) const result = await token.racePromise(promise) expect(result).toEqual(5) }) it('can be raced against a promise and lose to rejection', async () => { const promise = new Promise<number>((resolve, reject) => { setTimeout(() => reject('oops'), 1) }) try { await token.racePromise(promise) fail('Expected exception not thrown.') } catch (err) { expect(err).toEqual('oops') } }) it('can be raced against a promise and win', async () => { const promise = new Promise<number>((r) => {}) setTimeout(() => { cancel(reason) }, 10) try { await token.racePromise(promise) fail('expected error not thrown.') } catch (err) { expect(err).toBeInstanceOf(CancellationToken.CancellationError) expect(err.reason).toBe(reason) } }) }) describe('that was created via all', () => { let cancel1: (reason: any) => void let cancel2: (reason: any) => void let token1: CancellationToken let token2: CancellationToken let token: CancellationToken const reason1 = {} const reason2 = {} beforeEach(() => { ;({cancel: cancel1, token: token1} = CancellationToken.create()) ;({cancel: cancel2, token: token2} = CancellationToken.create()) token = CancellationToken.all(token1, token2) }) it('should be cancelled when all of the given tokens are cancelled', () => { // Cancel in reverse order to test that the reason array is ordered per the original `all` array. cancel2(reason2) cancel1(reason1) expect(token.isCancelled).toBe(true) expect(token.reason).toHaveLength(2) expect(token.reason).toEqual(expect.arrayContaining([reason1, reason2])) }) it('should not be cancelled when some of the given tokens are not cancelled', () => { cancel1(reason1) expect(token.isCancelled).toBe(false) }) it('should be cancelled immediately after creation if all of the given tokens are already cancelled', () => { cancel1(reason1) cancel2(reason2) const token = CancellationToken.all(token1, token2) expect(token.isCancelled).toBe(true) expect(token.reason).toHaveLength(2) expect(token.reason).toEqual(expect.arrayContaining([reason1, reason2])) }) it('is CONTINUE if any are CONTINUE', () => { const token = CancellationToken.all(CancellationToken.CONTINUE, token1) expect(token).toBe(CancellationToken.CONTINUE) }) }) describe('that was created via race', () => { let cancel1: (reason?: any) => void let cancel2: (reason?: any) => void let token1: CancellationToken let token2: CancellationToken let token: CancellationToken const reason = {} beforeEach(() => { ;({cancel: cancel1, token: token1} = CancellationToken.create()) ;({cancel: cancel2, token: token2} = CancellationToken.create()) token = CancellationToken.race(token1, token2) }) it('should be cancelled when at least one of the given tokens is cancelled', () => { cancel1(reason) expect(token.isCancelled).toBe(true) expect(token.reason).toBe(reason) }) it('should not be cancelled when none of the given tokens are cancelled', () => { expect(token.isCancelled).toBe(false) }) it('should be cancelled immediately after creation if one of the given tokens is already cancelled', () => { cancel1(reason) token = CancellationToken.race(token1, token2) expect(token.isCancelled).toBe(true) expect(token.reason).toBe(reason) }) it('cancellation inherits reason from first token in array when both are canceled', () => { cancel1(reason) cancel2({}) token = CancellationToken.race(token1, token2) expect(token.isCancelled).toBe(true) expect(token.reason).toBe(reason) }) }) describe('that is long-lived', () => { it('works in common async pattern', async () => { const {token, cancel} = CancellationToken.create() for (let i = 0; i < 10; i++) { await someOperationAsync(token) } }) async function someOperationAsync(token: CancellationToken): Promise<void> { token.throwIfCancelled() let timer: NodeJS.Timer let rejectPromise: (reason?: any) => void const unregister = token.onCancelled((reason) => { clearTimeout(timer) rejectPromise(new CancellationToken.CancellationError(reason)) }) const promise = new Promise<void>((resolve, reject) => { timer = setTimeout(() => { resolve() unregister() }, 0) rejectPromise = reject }) return promise } it('does not leak resolving promises', async () => { const {token, cancel} = CancellationToken.create() for (let i = 0; i < LEAK_LOOP_COUNT; i++) { await someFastOperationAsync(token) } }) async function someFastOperationAsync(token: CancellationToken): Promise<void> { token.throwIfCancelled() let rejectPromise: (reason?: any) => void const unregister = token.onCancelled((reason) => { rejectPromise(new CancellationToken.CancellationError(reason)) }) const promise = new Promise<void>((resolve) => resolve()) unregister() return promise } }) }) describe('The CONTINUE cancellation token', () => { it('claims to NOT be cancellable', () => { expect(CancellationToken.CONTINUE.canBeCancelled).toBe(false) }) it('is not cancelled', () => { expect(CancellationToken.CONTINUE.isCancelled).toBe(false) }) it('does not leak from onCancelled', () => { // Any event handlers to it should be immediately dropped rather than being stored, // forever leaking unbounded memory. for (var i = 0; i < LEAK_LOOP_COUNT; i++) { CancellationToken.CONTINUE.onCancelled(() => {}) } }) it('does not leak from or with completed promise', () => { for (var i = 0; i < LEAK_LOOP_COUNT; i++) { CancellationToken.CONTINUE.racePromise(Promise.resolve()) } }) }) describe('The CANCEL cancellation token', () => { it('is cancelled', () => { expect(CancellationToken.CANCELLED.isCancelled).toBe(true) }) it('claims to be cancellable', () => { expect(CancellationToken.CANCELLED.canBeCancelled).toBe(true) }) }) describe('A timeout cancellation token', () => { const TIMEOUT = 100 let cancel: (reason?: any) => void let token: CancellationToken const reason = {} beforeEach(() => { jest.useFakeTimers() ;({token, cancel} = CancellationToken.timeout(TIMEOUT)) }) it('is not cancelled before the time passes', () => { jest.advanceTimersByTime(TIMEOUT - 1) expect(token.isCancelled).toBeFalsy() }) it('is cancelled after the time passes', () => { jest.advanceTimersByTime(TIMEOUT) expect(token.isCancelled).toBeTruthy() }) it('is cancelled immediately when cancel is invoked', () => { cancel(reason) expect(token.isCancelled).toBeTruthy() expect(token.reason).toBe(reason) }) it('should not throw an error or change the reason when cancel is invoked multiple times', () => { cancel(reason) cancel({}) expect(token.isCancelled).toBeTruthy() expect(token.reason).toBe(reason) }) it('should not throw an error or change the reason when cancel is invoked after the time passes', () => { jest.advanceTimersByTime(TIMEOUT) cancel({}) expect(token.isCancelled).toBeTruthy() expect(token.reason).toBe(CancellationToken.timeout) }) it('claims to be cancellable', () => { expect(CancellationToken.CANCELLED.canBeCancelled).toBe(true) }) it('uses CancellationToken.timeout as its reason', () => { jest.advanceTimersByTime(TIMEOUT) expect(token.reason).toBe(CancellationToken.timeout) }) })