Actions and Suspensions
In this section, we'll cover one of the most fundamental operations in
Effection: action()
, and how we can use it as a safe alternative to
the Promise constructor.
Example: Sleep
Let's revisit our sleep operation from the introduction to operations:
export function sleep(duration: number): Operation<void> {
return action((resolve) => {
let timeoutId = setTimeout(resolve, duration);
return () => clearTimeout(timeoutId);
});
}
As we saw, no matter how the sleep operation ends, it always executes the
clearTimeout()
on its way out.
If we wanted to replicate our sleep()
functionality with promises, we'd need
to do something like accept an AbortSignal
as a second
argument to sleep()
, and then use it to prevent our event-loop callback from
leaking:
export function sleep(duration, signal) {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
} else {
let timeoutId = setTimeout(resolve, duration);
signal.addEventListener("abort", () => clearTimeout(timeoutId));
}
});
}
This functions properly, but is ham-fisted. Not only is the implementation non-obvious, but it's also cumbersome to use in practice because it involves first creating a signal, passing it around explicitly to everything, and then finally firing it manually when the program is over:
let controller = new AbortController();
let { signal } = controller;
await Promise.all([sleep(10, signal), sleep(1000, signal)]);
controller.abort();
With an action on the other hand, we get all the benefit as if an abort signal was there without sacrificing any clarity in achieving it.
Action Constructor
The action()
function provides a callback based API to create Effection operations. You don't need it all that often, but when you do it functions almost exactly like the
promise constructor. To see this, let's
use one of the examples from MDN
that uses promises to make a crude replica of the global fetch()
function. It manually creates an XHR, and hooks up the load
and error
events
to the resolve
and reject
functions respectively.
async function fetch(url) {
return await new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(xhr.statusText);
xhr.send();
});
}
Consulting the Async Rosetta Stone, we can substitute the async constructs for their Effection counterparts to arrive at an (almost) line for line translation. The only significant difference is that unlike the promise constructor, an action constructor must return a "finally" function to exit the action.
function* fetch(url) {
return yield* action((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(xhr.statusText);
xhr.send();
return () => { } // "finally" function place holder.
});
}
While this works works every bit as well as the promise based
implementation, it turns out that the example from MDN has a subtle
bug. In fact, it's the same subtle bug that afflicted the "racing
sleep" example in the introduction to
operations. If
we no longer care about the outcome of our fetch
operation, we will
"leak" its http request which will remain in flight until a response
is received. In the example below it does not matter which web request
"wins" the race to fetch the current weather, our process cannot exit
until both requests are have received a response.
await Promise.race([
fetch("https://openweathermap.org"),
fetch("https://open-meteo.org")
])
With Effection, this is easily fixed by calling abort()
in the finally function to
make sure that the request is cancelled when it is either resolved, rejected, or
passes out of scope.
function* fetch(url) {
return yield* action((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(xhr.statusText);
xhr.send();
return () => { xhr.abort(); }; // called in all cases
});
}
💡Almost every usage of the promise concurrency primitives will contain bugs born of leaked effects.