Skip to content

Instantly share code, notes, and snippets.

@FaberVitale
Last active December 11, 2021 17:10
Show Gist options
  • Save FaberVitale/0ea2adf79ac8854042808137308a1441 to your computer and use it in GitHub Desktop.
Save FaberVitale/0ea2adf79ac8854042808137308a1441 to your computer and use it in GitHub Desktop.
Task
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>
}
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