From 7f7ed81881b34ec7e73174d92ea926a791be9575 Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Mon, 10 Apr 2017 08:47:02 -0700 Subject: [PATCH 1/4] Add cancellation token package --- packages/cancellation-token/.gitignore | 4 ++ .../cancellation-token/__tests__/Token.ts | 44 +++++++++++++ .../__tests__/TokenSource.ts | 61 +++++++++++++++++++ packages/cancellation-token/index.ts | 2 + packages/cancellation-token/lib/Token.ts | 37 +++++++++++ .../cancellation-token/lib/TokenSource.ts | 35 +++++++++++ packages/cancellation-token/package.json | 19 ++++++ packages/cancellation-token/tsconfig.json | 10 +++ 8 files changed, 212 insertions(+) create mode 100644 packages/cancellation-token/.gitignore create mode 100644 packages/cancellation-token/__tests__/Token.ts create mode 100644 packages/cancellation-token/__tests__/TokenSource.ts create mode 100644 packages/cancellation-token/index.ts create mode 100644 packages/cancellation-token/lib/Token.ts create mode 100644 packages/cancellation-token/lib/TokenSource.ts create mode 100644 packages/cancellation-token/package.json create mode 100644 packages/cancellation-token/tsconfig.json 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..abeb8a77bd0a --- /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.canceled).toBe(false); + }); + + it('should defer cancellation queries to parent token source', () => { + const source = {isCancellationRequested: true}; + const token = new Token(source); + + expect(token.canceled).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..c4af08fb75d3 --- /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.token; + expect(token).toBeInstanceOf(Token); + expect(source.token).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..1376814ba6e5 --- /dev/null +++ b/packages/cancellation-token/lib/Token.ts @@ -0,0 +1,37 @@ +import {TokenSource} from './TokenSource'; + +export class Token { + public readonly cancellable: boolean; + + constructor(private readonly source?: TokenSource) { + this.cancellable = Boolean(source); + } + + get canBeCanceled(): boolean { + return this.cancellable; + } + + get canceled(): boolean { + if (this.source) { + return this.source.isCancellationRequested; + } + + return false; + } + + get isCancellationRequested(): boolean { + return this.canceled; + } + + onCancellationRequested(cb: () => void): void { + if (this.source) { + this.source.registerCancellationHandler(cb); + } + } + + throwIfCancellationRequested(reason?: string): void { + if (this.canceled) { + throw new Error(reason); + } + } +} diff --git a/packages/cancellation-token/lib/TokenSource.ts b/packages/cancellation-token/lib/TokenSource.ts new file mode 100644 index 000000000000..521ff3be1506 --- /dev/null +++ b/packages/cancellation-token/lib/TokenSource.ts @@ -0,0 +1,35 @@ +import {Token} from "./Token"; + +export class TokenSource { + private _cancellationRequested: boolean = false; + private _invokeAfterCancellation: Array<() => void> = []; + + get isCancellationRequested(): boolean { + return this._cancellationRequested; + } + + get token(): Token { + return new Token(this); + } + + 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); + } + } + } + } + + 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 + } +} From 028ee7d64dde32aa74ba967039f65f9ff6b4c6cd Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Tue, 23 May 2017 15:47:39 -0700 Subject: [PATCH 2/4] Add documentation for TokenSource/Token --- .../cancellation-token/__tests__/Token.ts | 4 +- packages/cancellation-token/lib/Token.ts | 42 ++++++++++++++++--- .../cancellation-token/lib/TokenSource.ts | 30 +++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/cancellation-token/__tests__/Token.ts b/packages/cancellation-token/__tests__/Token.ts index abeb8a77bd0a..5f271a02e6b9 100644 --- a/packages/cancellation-token/__tests__/Token.ts +++ b/packages/cancellation-token/__tests__/Token.ts @@ -6,14 +6,14 @@ describe('Token', () => { const token = new Token(); expect(token.cancellable).toBe(false); - expect(token.canceled).toBe(false); + expect(token.cancelled).toBe(false); }); it('should defer cancellation queries to parent token source', () => { const source = {isCancellationRequested: true}; const token = new Token(source); - expect(token.canceled).toBe(true); + expect(token.cancelled).toBe(true); source.isCancellationRequested = false; expect(token.isCancellationRequested).toBe(false); }); diff --git a/packages/cancellation-token/lib/Token.ts b/packages/cancellation-token/lib/Token.ts index 1376814ba6e5..900b661c58d1 100644 --- a/packages/cancellation-token/lib/Token.ts +++ b/packages/cancellation-token/lib/Token.ts @@ -1,17 +1,38 @@ 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); } - get canBeCanceled(): boolean { + /** + * Alias of this.cancellable + */ + get canBeCancelled(): boolean { return this.cancellable; } - get canceled(): boolean { + /** + * Whether the associated operation has already been cancelled. + */ + get cancelled(): boolean { if (this.source) { return this.source.isCancellationRequested; } @@ -19,19 +40,30 @@ export class Token { return false; } + /** + * Alias of this.canceled + */ get isCancellationRequested(): boolean { - return this.canceled; + return this.cancelled; } + /** + * 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.canceled) { - throw new Error(reason); + if (this.cancelled) { + throw new Error(`Operation cancelled${reason ? `: ${reason}` : ''}`); } } } diff --git a/packages/cancellation-token/lib/TokenSource.ts b/packages/cancellation-token/lib/TokenSource.ts index 521ff3be1506..817db93e5d80 100644 --- a/packages/cancellation-token/lib/TokenSource.ts +++ b/packages/cancellation-token/lib/TokenSource.ts @@ -1,17 +1,42 @@ 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; } + /** + * Creates a new Token object linked to this TokenSource (i.e., one that + * will signal cancellation when this source has been cancelled). + */ get token(): Token { return new Token(this); } + /** + * 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; @@ -25,6 +50,11 @@ export class TokenSource { } } + /** + * 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); From 37a7471ff1fc7d8bb5fc93bd6947632262fae885 Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Wed, 24 May 2017 10:36:38 -0700 Subject: [PATCH 3/4] Remove alias fields and replace token getter with method --- .../cancellation-token/__tests__/Token.ts | 6 ++--- .../__tests__/TokenSource.ts | 4 ++-- packages/cancellation-token/lib/Token.ts | 22 ++++--------------- .../cancellation-token/lib/TokenSource.ts | 12 ++++------ 4 files changed, 13 insertions(+), 31 deletions(-) diff --git a/packages/cancellation-token/__tests__/Token.ts b/packages/cancellation-token/__tests__/Token.ts index 5f271a02e6b9..2c8ba2261fc6 100644 --- a/packages/cancellation-token/__tests__/Token.ts +++ b/packages/cancellation-token/__tests__/Token.ts @@ -5,15 +5,15 @@ describe('Token', () => { it('should not be cancellable if no source provided at construction', () => { const token = new Token(); - expect(token.cancellable).toBe(false); - expect(token.cancelled).toBe(false); + expect(token.canBeCancelled).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.cancelled).toBe(true); + expect(token.isCancellationRequested).toBe(true); source.isCancellationRequested = false; expect(token.isCancellationRequested).toBe(false); }); diff --git a/packages/cancellation-token/__tests__/TokenSource.ts b/packages/cancellation-token/__tests__/TokenSource.ts index c4af08fb75d3..a042f0f011d2 100644 --- a/packages/cancellation-token/__tests__/TokenSource.ts +++ b/packages/cancellation-token/__tests__/TokenSource.ts @@ -6,9 +6,9 @@ jest.useFakeTimers(); describe('TokenSource', () => { it('should return a new token on each property access', () => { const source = new TokenSource(); - const token = source.token; + const token = source.getToken(); expect(token).toBeInstanceOf(Token); - expect(source.token).not.toBe(token); + expect(source.getToken()).not.toBe(token); }); it( diff --git a/packages/cancellation-token/lib/Token.ts b/packages/cancellation-token/lib/Token.ts index 900b661c58d1..f89cc62016d1 100644 --- a/packages/cancellation-token/lib/Token.ts +++ b/packages/cancellation-token/lib/Token.ts @@ -12,27 +12,20 @@ export class Token { * Whether the associated operation may be cancelled at some point in the * future. */ - public readonly cancellable: boolean; + public readonly canBeCancelled: 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); - } - - /** - * Alias of this.cancellable - */ - get canBeCancelled(): boolean { - return this.cancellable; + this.canBeCancelled = Boolean(source); } /** * Whether the associated operation has already been cancelled. */ - get cancelled(): boolean { + get isCancellationRequested(): boolean { if (this.source) { return this.source.isCancellationRequested; } @@ -40,13 +33,6 @@ export class Token { return false; } - /** - * Alias of this.canceled - */ - get isCancellationRequested(): boolean { - return this.cancelled; - } - /** * Registers a handler to be invoked when cancellation is requested. If * cancellation has already been requested, the handler will be invoked on @@ -62,7 +48,7 @@ export class Token { * Throws an error if the associated operation has already been cancelled. */ throwIfCancellationRequested(reason?: string): void { - if (this.cancelled) { + 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 index 817db93e5d80..dbc9dd99747d 100644 --- a/packages/cancellation-token/lib/TokenSource.ts +++ b/packages/cancellation-token/lib/TokenSource.ts @@ -24,14 +24,6 @@ export class TokenSource { return this._cancellationRequested; } - /** - * Creates a new Token object linked to this TokenSource (i.e., one that - * will signal cancellation when this source has been cancelled). - */ - get token(): Token { - return new Token(this); - } - /** * Declares the operation associated with this TokenSource to be cancelled * and invokes any registered cancellation handlers. The latter may be @@ -50,6 +42,10 @@ export class TokenSource { } } + 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, From 714e15dd481cc6791e67400b231aad9647065b87 Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Wed, 24 May 2017 11:02:21 -0700 Subject: [PATCH 4/4] Restore docs to getToken() --- packages/cancellation-token/__tests__/Token.ts | 2 +- packages/cancellation-token/lib/Token.ts | 4 ++-- packages/cancellation-token/lib/TokenSource.ts | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cancellation-token/__tests__/Token.ts b/packages/cancellation-token/__tests__/Token.ts index 2c8ba2261fc6..55283abc4e94 100644 --- a/packages/cancellation-token/__tests__/Token.ts +++ b/packages/cancellation-token/__tests__/Token.ts @@ -5,7 +5,7 @@ describe('Token', () => { it('should not be cancellable if no source provided at construction', () => { const token = new Token(); - expect(token.canBeCancelled).toBe(false); + expect(token.cancellable).toBe(false); expect(token.isCancellationRequested).toBe(false); }); diff --git a/packages/cancellation-token/lib/Token.ts b/packages/cancellation-token/lib/Token.ts index f89cc62016d1..df942459a19a 100644 --- a/packages/cancellation-token/lib/Token.ts +++ b/packages/cancellation-token/lib/Token.ts @@ -12,14 +12,14 @@ export class Token { * Whether the associated operation may be cancelled at some point in the * future. */ - public readonly canBeCancelled: boolean; + 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.canBeCancelled = Boolean(source); + this.cancellable = Boolean(source); } /** diff --git a/packages/cancellation-token/lib/TokenSource.ts b/packages/cancellation-token/lib/TokenSource.ts index dbc9dd99747d..be7658f36e80 100644 --- a/packages/cancellation-token/lib/TokenSource.ts +++ b/packages/cancellation-token/lib/TokenSource.ts @@ -42,6 +42,10 @@ export class TokenSource { } } + /** + * 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); }