diff --git a/packages/cancellation-token/.gitignore b/packages/cancellation-token/.gitignore new file mode 100644 index 000000000000..b5d6f1c7b0fa --- /dev/null +++ b/packages/cancellation-token/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +*.js +*.js.map +*.d.ts diff --git a/packages/cancellation-token/__tests__/Token.ts b/packages/cancellation-token/__tests__/Token.ts new file mode 100644 index 000000000000..55283abc4e94 --- /dev/null +++ b/packages/cancellation-token/__tests__/Token.ts @@ -0,0 +1,44 @@ +import {Token} from "../lib/Token"; +import {TokenSource} from "../lib/TokenSource"; + +describe('Token', () => { + it('should not be cancellable if no source provided at construction', () => { + const token = new Token(); + + expect(token.cancellable).toBe(false); + expect(token.isCancellationRequested).toBe(false); + }); + + it('should defer cancellation queries to parent token source', () => { + const source = {isCancellationRequested: true}; + const token = new Token(source); + + expect(token.isCancellationRequested).toBe(true); + source.isCancellationRequested = false; + expect(token.isCancellationRequested).toBe(false); + }); + + it( + 'should register cancellation handlers when onCancellationRequested called', + () => { + const source = new TokenSource(); + source.registerCancellationHandler = jest.fn(); + const token = new Token(source); + + const cb = () => {}; + token.onCancellationRequested(cb); + expect(source.registerCancellationHandler).toHaveBeenCalledWith(cb); + } + ); + + it( + 'should throw if cancellation requested and throwIfCancellationRequested called', + () => { + const token = new Token({isCancellationRequested: true}); + + expect(() => { + token.throwIfCancellationRequested('PANIC PANIC PANIC'); + }).toThrow('PANIC PANIC PANIC'); + } + ); +}); diff --git a/packages/cancellation-token/__tests__/TokenSource.ts b/packages/cancellation-token/__tests__/TokenSource.ts new file mode 100644 index 000000000000..a042f0f011d2 --- /dev/null +++ b/packages/cancellation-token/__tests__/TokenSource.ts @@ -0,0 +1,61 @@ +import {Token} from "../lib/Token"; +import {TokenSource} from "../lib/TokenSource"; + +jest.useFakeTimers(); + +describe('TokenSource', () => { + it('should return a new token on each property access', () => { + const source = new TokenSource(); + const token = source.getToken(); + expect(token).toBeInstanceOf(Token); + expect(source.getToken()).not.toBe(token); + }); + + it( + 'should report that cancellation was requested after cancel called', + () => { + const source = new TokenSource(); + expect(source.isCancellationRequested).toBe(false); + source.cancel(); + expect(source.isCancellationRequested).toBe(true); + } + ); + + it('should invoke registered cancellation handlers on cancellation', () => { + const source = new TokenSource(); + const cb = jest.fn(); + source.registerCancellationHandler(cb); + expect(cb).not.toHaveBeenCalled(); + source.cancel(); + jest.runAllTimers(); + expect(cb).toHaveBeenCalled(); + }); + + it( + 'should not invoke registered handlers if silent cancellation requested', + () => { + const source = new TokenSource(); + const cb = jest.fn(); + source.registerCancellationHandler(cb); + expect(cb).not.toHaveBeenCalled(); + source.cancel(false); + jest.runAllTimers(); + expect(cb).not.toHaveBeenCalled(); + } + ); + + it( + 'should invoke cancellation handlers immediately if cancellation already requested', + () => { + const timeoutMock = setTimeout; + + const source = new TokenSource(); + const cb = jest.fn(); + source.cancel(false); + source.registerCancellationHandler(cb); + expect(timeoutMock).toHaveBeenCalledWith(cb, 0); + jest.runAllTimers(); + expect(cb).toHaveBeenCalled(); + } + ); +}); diff --git a/packages/cancellation-token/index.ts b/packages/cancellation-token/index.ts new file mode 100644 index 000000000000..ae19abdda74a --- /dev/null +++ b/packages/cancellation-token/index.ts @@ -0,0 +1,2 @@ +export * from "./lib/Token"; +export * from "./lib/TokenSource"; diff --git a/packages/cancellation-token/lib/Token.ts b/packages/cancellation-token/lib/Token.ts new file mode 100644 index 000000000000..df942459a19a --- /dev/null +++ b/packages/cancellation-token/lib/Token.ts @@ -0,0 +1,55 @@ +import {TokenSource} from './TokenSource'; + +/** + * @see {TokenSource} + * + * Holders of a Token object may query if the associated operation has been + * cancelled, register cancellation handlers, and conditionally throw an Error + * if the operation has already been cancelled. + */ +export class Token { + /** + * Whether the associated operation may be cancelled at some point in the + * future. + */ + public readonly cancellable: boolean; + + /** + * Creates a new Token linked to a provided TokenSource. If no source is + * provided, the Token cannot be cancelled. + */ + constructor(private readonly source?: TokenSource) { + this.cancellable = Boolean(source); + } + + /** + * Whether the associated operation has already been cancelled. + */ + get isCancellationRequested(): boolean { + if (this.source) { + return this.source.isCancellationRequested; + } + + return false; + } + + /** + * Registers a handler to be invoked when cancellation is requested. If + * cancellation has already been requested, the handler will be invoked on + * the next tick of the event loop. + */ + onCancellationRequested(cb: () => void): void { + if (this.source) { + this.source.registerCancellationHandler(cb); + } + } + + /** + * Throws an error if the associated operation has already been cancelled. + */ + throwIfCancellationRequested(reason?: string): void { + if (this.isCancellationRequested) { + throw new Error(`Operation cancelled${reason ? `: ${reason}` : ''}`); + } + } +} diff --git a/packages/cancellation-token/lib/TokenSource.ts b/packages/cancellation-token/lib/TokenSource.ts new file mode 100644 index 000000000000..be7658f36e80 --- /dev/null +++ b/packages/cancellation-token/lib/TokenSource.ts @@ -0,0 +1,65 @@ +import {Token} from "./Token"; + +/** + * The AWS SDK uses a TokenSource/Token model to allow for cooperative + * cancellation of asynchronous operations. When initiating such an operation, + * the caller can create a TokenSource and then provide linked tokens to + * subtasks. This allows a single source to signal to multiple consumers that a + * cancellation has been requested without dictating how that cancellation + * should be handled. + * + * Holders of a TokenSource object may create new tokens, register cancellation + * handlers, or declare an operation cancelled (thereby invoking any registered + * handlers). + */ +export class TokenSource { + private _cancellationRequested: boolean = false; + private _invokeAfterCancellation: Array<() => void> = []; + + /** + * Whether the operation associated with this TokenSource has been + * cancelled. + */ + get isCancellationRequested(): boolean { + return this._cancellationRequested; + } + + /** + * Declares the operation associated with this TokenSource to be cancelled + * and invokes any registered cancellation handlers. The latter may be + * skipped if so desired. + */ + cancel(invokeRegisteredActions: boolean = true): void { + this._cancellationRequested = true; + + if (invokeRegisteredActions) { + while (this._invokeAfterCancellation.length > 0) { + let action = this._invokeAfterCancellation.shift(); + if (action) { + setTimeout(action, 0); + } + } + } + } + + /** + * Creates a new Token object linked to this TokenSource (i.e., one that + * will signal cancellation when this source has been cancelled). + */ + getToken(): Token { + return new Token(this); + } + + /** + * Adds a handler to be invoked when cancellation of the associated + * operation has been requested. If cancellation has already been requested, + * the handler will be invoked on the next tick of the event loop. + */ + registerCancellationHandler(handler: () => void): void { + if (this._cancellationRequested) { + setTimeout(handler, 0); + } else { + this._invokeAfterCancellation.push(handler); + } + } +} diff --git a/packages/cancellation-token/package.json b/packages/cancellation-token/package.json new file mode 100644 index 000000000000..45155308eef2 --- /dev/null +++ b/packages/cancellation-token/package.json @@ -0,0 +1,19 @@ +{ + "name": "@aws/cancellation-token", + "version": "0.0.1", + "private": true, + "description": "A simple cancellation token library", + "main": "index.js", + "scripts": { + "prepublishOnly": "tsc", + "pretest": "tsc", + "test": "jest" + }, + "author": "aws-javascript-sdk-team@amazon.com", + "license": "UNLICENSED", + "devDependencies": { + "@types/jest": "^19.2.2", + "jest": "^19.0.2", + "typescript": "^2.3" + } +} diff --git a/packages/cancellation-token/tsconfig.json b/packages/cancellation-token/tsconfig.json new file mode 100644 index 000000000000..a4a7e6b95d57 --- /dev/null +++ b/packages/cancellation-token/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "strict": true, + "sourceMap": true, + "declaration": true, + "stripInternal": true + } +}