From 3048ec52e1eeb73c0ae68c3f9c04aed6799d8a63 Mon Sep 17 00:00:00 2001 From: Ian Flanigan Date: Sun, 12 Jun 2022 18:06:58 +0200 Subject: [PATCH] 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 --- js/components/Apple2.tsx | 9 ++- js/components/util/promises.ts | 14 +++- test/components/util/promises.spec.ts | 106 ++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 test/components/util/promises.spec.ts diff --git a/js/components/Apple2.tsx b/js/components/Apple2.tsx index feb9a48..82173ce 100644 --- a/js/components/Apple2.tsx +++ b/js/components/Apple2.tsx @@ -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]); diff --git a/js/components/util/promises.ts b/js/components/util/promises.ts index d04aa72..704fec4 100644 --- a/js/components/util/promises.ts +++ b/js/components/util/promises.ts @@ -12,11 +12,17 @@ export function noAwait Promise>(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): void { - noAwait(f)(); +export function spawn(f: (abortSignal: AbortSignal) => Promise): AbortController { + const abortController = new AbortController(); + noAwait(f)(abortController.signal); + return abortController; } /** diff --git a/test/components/util/promises.spec.ts b/test/components/util/promises.spec.ts new file mode 100644 index 0000000..41b2481 --- /dev/null +++ b/test/components/util/promises.spec.ts @@ -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)); +} \ No newline at end of file