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:
Ian Flanigan 2022-06-12 18:06:58 +02:00 committed by GitHub
parent d7cb6997d1
commit 3048ec52e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 124 additions and 5 deletions

View File

@ -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]);

View File

@ -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;
}
/**

View File

@ -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));
}