Interruptable spawn (#132)
* Add `spawn` as a way of calling promise-returning blocks This change adds `spawn` which takes a no-argument, promise-returning function, calls it, and returns `void`. This makes it easy to call async blocks from `useEffect` and other places that don't take async functions, but also makes such calls explicit. * Adds interruptability to `spawn` Now, the task function passed to `spawn` can take an `Interrupted` argument, which is merely a method that returns `true` if the task should stop doing work. Likewise, `spawn` returns an `Interrupt` function that causes the `Interrupted` function to return `true`. * Change to using `AbortController` and `AbortSignal` Before, `spawn` used functions to interrupt and determine interruption state. Now, based on feedback from @whscullin, it uses `AbortController` and `AbortSignal`. Tests now show how the controller can be used to abort long-running tasks and API calls in the `spawn`. The also show how signals can be chained using `addEventListener`. * Fix `Apple2.tsx` Forgot to change it to use `AbortController` and `AbortSignal`. Co-authored-by: Will Scullin <scullin@scullin.com>
This commit is contained in:
parent
d7cb6997d1
commit
3048ec52e1
|
@ -55,19 +55,26 @@ export const Apple2 = (props: Apple2Props) => {
|
|||
...props,
|
||||
};
|
||||
const apple2 = new Apple2Impl(options);
|
||||
spawn(async () => {
|
||||
const controller = spawn(async (signal) => {
|
||||
try {
|
||||
await apple2.ready;
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setApple2(apple2);
|
||||
setIO(apple2.getIO());
|
||||
setCPU(apple2.getCPU());
|
||||
await drivesReady.ready;
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
apple2.reset();
|
||||
apple2.run();
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
return controller.abort();
|
||||
}
|
||||
}, [props, drivesReady]);
|
||||
|
||||
|
|
|
@ -12,11 +12,17 @@ export function noAwait<F extends (...args: unknown[]) => Promise<unknown>>(f: F
|
|||
}
|
||||
|
||||
/**
|
||||
* Calls the given `Promise`-returning function and returns void, signalling that it is
|
||||
* explicitly not awaited.
|
||||
* Calls the given `Promise`-returning function, `f`, and does not await
|
||||
* the result. The function `f` is passed an {@link Interrupted} function
|
||||
* that returns `true` if it should stop doing work. `spawn` returns an
|
||||
* {@link Interrupt} function that, when called, causes the `Interrupted`
|
||||
* function to return `true`. This can be used in `useEffect` calls as the
|
||||
* cleanup function.
|
||||
*/
|
||||
export function spawn(f: () => Promise<unknown>): void {
|
||||
noAwait(f)();
|
||||
export function spawn(f: (abortSignal: AbortSignal) => Promise<unknown>): AbortController {
|
||||
const abortController = new AbortController();
|
||||
noAwait(f)(abortController.signal);
|
||||
return abortController;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
/** @jest-environment jsdom */
|
||||
|
||||
import { Ready, spawn } from 'js/components/util/promises';
|
||||
|
||||
describe('promises', () => {
|
||||
describe('spawn', () => {
|
||||
it('returns an AbortController', () => {
|
||||
const controller = spawn(() => Promise.resolve(1));
|
||||
expect(controller).not.toBeNull();
|
||||
expect(controller).toBeInstanceOf(AbortController);
|
||||
});
|
||||
|
||||
it('passes an AbortSignal to the target function', () => {
|
||||
let signalCapture: AbortSignal | null = null;
|
||||
spawn((signal) => {
|
||||
signalCapture = signal;
|
||||
return Promise.resolve(1);
|
||||
});
|
||||
expect(signalCapture).not.toBeNull();
|
||||
expect(signalCapture).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('has the controller hooked up to the signal', async () => {
|
||||
let isAborted = false;
|
||||
|
||||
const controllerIsAborted = new Ready();
|
||||
const spawnHasRecorded = new Ready();
|
||||
|
||||
const controller = spawn(async (signal) => {
|
||||
await controllerIsAborted.ready;
|
||||
isAborted = signal.aborted;
|
||||
spawnHasRecorded.onReady();
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
controllerIsAborted.onReady();
|
||||
|
||||
await spawnHasRecorded.ready;
|
||||
expect(isAborted).toBe(true);
|
||||
});
|
||||
|
||||
it('allows long-runing tasks to be stopped', async () => {
|
||||
let isFinished = false;
|
||||
|
||||
const innerReady = new Ready();
|
||||
const innerFinished = new Ready();
|
||||
|
||||
const controller = spawn(async (signal) => {
|
||||
innerReady.onReady();
|
||||
let i = 0;
|
||||
while (!signal.aborted) {
|
||||
i++;
|
||||
await tick();
|
||||
}
|
||||
isFinished = true;
|
||||
console.log(i);
|
||||
innerFinished.onReady();
|
||||
});
|
||||
await innerReady.ready;
|
||||
await tick();
|
||||
controller.abort();
|
||||
await innerFinished.ready;
|
||||
|
||||
expect(isFinished).toBe(true);
|
||||
});
|
||||
|
||||
it('allows nesting via listeners', async () => {
|
||||
let isInnerAborted = false;
|
||||
|
||||
const innerReady = new Ready();
|
||||
const outerReady = new Ready();
|
||||
const abortRecorded = new Ready();
|
||||
|
||||
const controller = spawn(async (signal) => {
|
||||
const innerController = spawn(async (innerSignal) => {
|
||||
await innerReady.ready;
|
||||
isInnerAborted = innerSignal.aborted;
|
||||
abortRecorded.onReady();
|
||||
});
|
||||
// TODO(flan): Chain signal.reason when calling innerController.abort()
|
||||
// once jsdom 19 has wider adoption (currently on 16.6.0). Likewise there
|
||||
// are some subtle problems with signal.addEventListener that should be
|
||||
// addressed by https://github.com/jsdom/jsdom/pull/3347.
|
||||
// signal.addEventListener('abort', () => innerController.abort(signal.reason));
|
||||
signal.addEventListener('abort', () => innerController.abort());
|
||||
await outerReady.ready;
|
||||
});
|
||||
|
||||
// Abort the outer controller, but don't let the outer block run.
|
||||
controller.abort();
|
||||
// Let the inner block run.
|
||||
innerReady.onReady();
|
||||
await abortRecorded.ready;
|
||||
|
||||
// Inner block is aborted.
|
||||
expect(isInnerAborted).toBe(true);
|
||||
|
||||
// Let outer block finish.
|
||||
outerReady.onReady();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function tick() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
Loading…
Reference in New Issue