Effection Logo

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.

  • PreviousOperations
  • NextResources