Skip to content

Commit

Permalink
fix(task): fixed task abort resolutions
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewcourtice committed Mar 1, 2022
1 parent 5251cfe commit 29e1005
Show file tree
Hide file tree
Showing 4 changed files with 60 additions and 46 deletions.
10 changes: 10 additions & 0 deletions packages/task/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export class TaskAbortError extends Error {

public reason: any;

constructor(reason?: any) {
super('Task aborted');
this.reason = reason;
}

}
72 changes: 37 additions & 35 deletions packages/task/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,65 @@
import {
TaskAbortError,
} from './errors';

import type {
Product,
TaskAbortCallback,
TaskAbortedCallback,
TaskExecutor,
} from './types';

export * from './errors';
export * from './types';

function safeRun<TResult>(bodyInvokee: Product<TResult>, finallyInvokee: Product): Product<TResult> {
return (...args: any[]) => {
try {
return bodyInvokee(...args);
} finally {
finallyInvokee();
}
};
}

export default class Task<T = void> extends Promise<T> {
export default class Task<TResult = void> extends Promise<TResult> {

private controller: AbortController;
private abortReason: unknown;

constructor(executor: TaskExecutor<T>, controller: AbortController = new AbortController()) {
constructor(executor: TaskExecutor<TResult>, controller: AbortController = new AbortController()) {
if (controller.signal.aborted) {
throw new Error('Cannot attach task to an already aborted controller');
}

const listeners = new Set<Product>();

const addListener = (listener: Product) => {
listeners.add(listener);
controller.signal.addEventListener('abort', listener);
};

const removeListener = (listener: Product) => {
listeners.delete(listener);
controller.signal.removeEventListener('abort', listener);
};
const listeners = new Set<TaskAbortCallback>();
let isAborting = false;

const cleanup = () => {
if (listeners.size > 0) {
listeners.forEach(removeListener);
const execResolution = (callback: Product) => {
if (!isAborting) {
callback();
}
};

super((_resolve, _reject) => {
const resolve = safeRun(_resolve, cleanup);
const reject = safeRun(_reject, cleanup);
let finaliser: TaskAbortedCallback = (reason) => _reject(new TaskAbortError(reason));

const resolve = (value: TResult | PromiseLike<TResult>) => execResolution(() => _resolve(value));
const reject = (reason?: any) => execResolution(() => _reject(reason));
const onAbort = (callback: TaskAbortCallback) => listeners.add(callback);
const onAborted = (callback: TaskAbortedCallback) => finaliser = callback;

const abort = () => {
controller.signal.removeEventListener('abort', abort);

const onAbort = (callback: TaskAbortCallback) => {
const listener = safeRun(
() => callback(this.abortReason),
() => removeListener(listener),
) as Product;
isAborting = true;

addListener(listener);
listeners.forEach(listener => {
try {
listener(this.abortReason);
} finally {
listeners.delete(listener);
}
});

isAborting = false;

finaliser(this.abortReason);
};

executor(resolve, reject, controller, onAbort);
controller.signal.addEventListener('abort', abort);

executor(resolve, reject, controller, onAbort, onAborted);
});

this.controller = controller;
Expand Down
12 changes: 8 additions & 4 deletions packages/task/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export type Product<TResult = void> = (...args: any[]) => TResult;
export type TaskResolve<TResult> = (value: TResult | PromiseLike<TResult>) => void;
export type TaskReject = (reason?: unknown) => unknown;
export type TaskAbortCallback = (reason?: unknown) => void;
export type TaskAbortedCallback = (reason?: unknown) => void;

export type TaskExecutor<T> = (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: unknown) => unknown,
export type TaskExecutor<TResult> = (
resolve: TaskResolve<TResult>,
reject: TaskReject,
controller: AbortController,
onAbort: (callback: TaskAbortCallback) => void
onAbort: (callback: TaskAbortCallback) => void,
onAborted: (callback: TaskAbortedCallback) => void
) => void;
12 changes: 5 additions & 7 deletions packages/task/test/task.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Task from '../src';
import Task, {
TaskAbortError,
} from '../src';

import {
describe,
Expand All @@ -9,21 +11,17 @@ import {
describe('Task', () => {

test('Should handle cancellation', () => {

const runTimeout = (timeout: number) => new Task<boolean>((resolve, reject, controller, onAbort) => {
const handle = setTimeout(() => resolve(true), timeout);

onAbort(() => {
clearTimeout(handle);
reject('aborted');
});
onAbort(() => clearTimeout(handle));
});

const task = runTimeout(1000);

setTimeout(() => task.abort(), 100);

return expect(task).rejects.toMatch('aborted');
expect(task).rejects.toBeInstanceOf(TaskAbortError);
});

});

0 comments on commit 29e1005

Please sign in to comment.