Last active
December 11, 2021 17:10
-
-
Save FaberVitale/0ea2adf79ac8854042808137308a1441 to your computer and use it in GitHub Desktop.
Task
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export interface Task<T> extends Promise<T> { | |
/** | |
* The abort signal of the task | |
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | |
*/ | |
signal: AbortSignal | |
/** | |
* Cancel a task only if it has not been settled. | |
* @param reason | |
*/ | |
cancel(reason?: string): void | |
} | |
export interface TaskExecutor<T> { | |
( | |
resolve: (value: T | PromiseLike<T>) => void, | |
reject: (reason?: any) => void, | |
signal: AbortSignal, | |
): void | |
} | |
interface Func<T, Args extends readonly any[]> { | |
(...args: Args): T | |
} | |
function noop() {} | |
export class TaskAbortError implements Error { | |
name: string | |
message: string | |
constructor(public reason?: string) { | |
this.name = 'TaskAbortError' | |
this.message = `task cancelled` + (reason != null ? `: "${reason}"` : '') | |
} | |
} | |
function safeRun<T, Args extends any[]>( | |
func: Func<T, Args>, | |
cleanUp: () => void | |
): Func<T, Args> { | |
return (...args: Args) => { | |
try { | |
return func(...args) | |
} finally { | |
cleanUp() | |
} | |
} | |
} | |
/** | |
* Creates a task given an input `TaskExecutor`. | |
* | |
* ### Task | |
* | |
* A task instance represents a cancellable asynchronous operation; | |
* it is built on top `Promise` and exposes 2 additional properties: | |
* | |
* - `cancel`: aborts current task if it is not already settled. | |
* - `signal`: an `AbortSignal` of the current task. | |
* | |
* It can be cancelled only if it has not already been settled; | |
* calling `cancel` on a task already settled has no effect. | |
* | |
* ### TaskExecutor | |
* | |
* A task executor is promise executor that gets invoked | |
* with an `AbortAignal` as additional argument. | |
* | |
* @param taskExecutor | |
* @returns | |
* @throws {TypeError} if executor is not a function. | |
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | |
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise | |
*/ | |
export function createTask<T>(taskExecutor: TaskExecutor<T>): Task<T> { | |
const controller = new AbortController() | |
let isSettled = false | |
let abortReason: string | undefined | |
const listeners: Set<Func<void, []>> = new Set() | |
const addListener = (cb: Func<void, []>) => { | |
listeners.add(cb) | |
controller.signal.addEventListener('abort', () => cb()) | |
} | |
const removeListener = (cb: Func<void, []>) => { | |
listeners.delete(cb) | |
controller.signal.removeEventListener('abort', () => cb()) | |
} | |
const cleanUp = () => { | |
isSettled = true | |
if (listeners.size > 0) { | |
listeners.forEach(removeListener) | |
} | |
} | |
/** | |
* Executors runs synchronously | |
* @see https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-promise-constructor | |
*/ | |
const promise = new Promise<T>((innerResolve, innerReject) => { | |
const resolve = safeRun(innerResolve, cleanUp) | |
const reject = safeRun(innerReject, cleanUp) | |
addListener(() => reject(new TaskAbortError(abortReason))) | |
try { | |
taskExecutor(resolve, reject, controller.signal) | |
} catch (err) { | |
reject(err) | |
} | |
}) | |
const readonly = { | |
writable: false, | |
enumerable: false, | |
configurable: false, | |
} | |
return Object.defineProperties(promise, { | |
signal: { ...readonly, value: controller.signal }, | |
cancel: { | |
...readonly, | |
value: (reason?: string) => { | |
if (!isSettled) { | |
abortReason = reason | |
controller.abort() | |
} | |
}, | |
}, | |
}) as Task<T> | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createTask, TaskAbortError } from './task' | |
interface Func<T, Args extends readonly any[]> { | |
(...args: Args): T | |
} | |
describe('task', () => { | |
it('works with async await', async () => { | |
const output = await createTask((resolve) => { | |
resolve(42) | |
}) | |
expect(output).toBe(42) | |
try { | |
const output = await createTask((_, reject) => { | |
reject(52) | |
}) | |
} catch (err) { | |
expect(err).toBe(52) | |
} | |
}) | |
it('does not block rejected values', async () => { | |
try { | |
await createTask((_, reject) => { | |
reject(303) | |
}).catch() | |
} catch (err) { | |
expect(err).toBe(303) | |
} | |
const sampleErr = new TypeError('qui que quod') | |
try { | |
await createTask(() => { | |
throw 2020 | |
}).catch() | |
} catch (err) { | |
expect(err).toBe(2020) | |
} | |
}) | |
it('rejects with a task cancellation exception if task gets cancelled before completion', async () => { | |
try { | |
const task = createTask((resolve) => resolve(2)) | |
task.cancel() | |
await task | |
} catch (err) { | |
expect(err).toBeInstanceOf(TaskAbortError) | |
} | |
}) | |
it('notifies the executor if the task gets cancelled', async () => { | |
const cleanUp = jest.fn() | |
const task = createTask<number>((resolve, _, signal) => { | |
signal.addEventListener('abort', cleanUp, { once: true }); | |
setTimeout(resolve, 0, 23) | |
}) | |
task.cancel() | |
task.catch(() => { | |
expect(cleanUp).toBeCalledTimes(1) | |
}) | |
expect(task).rejects.toBeInstanceOf(TaskAbortError) | |
}) | |
it('cancel is noop if a task as already been settled', async () => { | |
const abortListener = jest.fn() | |
const deferred = <T, U>(cb: Func<U, [T]>) => { | |
return (val: T): U => cb(val); | |
} | |
let resolveTrigger: Func<void, [number]> | null = null; | |
const task = createTask<number>((resolve) => { | |
resolveTrigger = deferred(resolve) | |
}) | |
task.signal.addEventListener('abort', abortListener); | |
resolveTrigger!(45); | |
task.cancel(); | |
const output = await task; | |
expect(output).toBe(45); | |
expect(abortListener).not.toHaveBeenCalled(); | |
expect(task.signal).toHaveProperty('aborted', false); | |
}) | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment