Skip to content

Instantly share code, notes, and snippets.

@shaseley
Created March 27, 2020 20:42
Show Gist options
  • Save shaseley/a2d6cc347f5f8f9c51809e914125ce51 to your computer and use it in GitHub Desktop.
Save shaseley/a2d6cc347f5f8f9c51809e914125ce51 to your computer and use it in GitHub Desktop.

Supporting Multiple Signals on the Web Platform

Introduction

The signal/controller pattern was introduced as the way to control ongoing async work, initially for cancellation via AbortController and AbortSignal. scheduler.postTask is the first instance of an API that needs to extend this functionality, adding reprioritization of queued async tasks in addition to cancellation.

There are two general ways of doing this:

  1. Extend existing signals/controllers through inheritance
  2. Create new signal/control controller pairs, optionally combining them using interface mixins

Note: Multiple inheritance is not supported.

The first option — and our current approach — has ergonomic and encapsulation benefits, but leads to complexities when separately propagating signal components due to signal conflation. This doc explores these two approaches in more detail and attempts to think through the long-term implications.

Current Approach: Extending AbortController and AbortSignal

Our current approach is to extend AbortController and AbortSignal through inheritance, combining them into a single TaskSignal, which is based on the proposal for FetchController.

TaskController extends AbortController

interface TaskController : AbortController {
    constructor(optional TaskPriority priority = "user-visible");
    void setPriority(TaskPriority priority);
};

and TaskSignal extends AbortSignal

interface TaskSignal : AbortSignal {
  readonly attribute TaskPriority priority;
  attribute EventHandler onprioritychange;
};

With this approach, there is only one controller and associated signal for each task:

const controller = new TaskController('background');
const signal = controller.signal;

scheduler.postTask(foo, {signal});

// State.
console.log(signal.aborted);
console.log(signal.priority);

// Control.
controller.setPriority('user-blocking');
controller.abort();

Advantages

  1. Encapsulation: All of the task operations and state are encapsulated in TaskController and TaskSignal, respectively

  2. Minimizes Complexity: The approach only allows one signal — and by extension one controller — to directly affect a task. A single controller and signal also provide an ergonomic benefit.

  3. Familiar API Shape: The API shape looks a lot like existing APIs that use AbortSignal, like fetch

  4. Compatibility with existing APIs: Since TaskSignal is an AbortSignal, one can be used in APIs that accept an AbortSignal

Disadvantages

  1. Conceptual correctness: Intuitively, a signal represents an individual event, like Linux signals or signals and slots. A TaskSignal, however, doesn't represent an individual event, but rather a pair of events (and associated state) that occur independently. Conflating different events into a single signal without separability seems conceptually incorrect, which has consequences for signal propagation.

  2. Propagating individual signal components is more cumbersome: How do we separately propagate the different signal components? This isn't straightforward in this approach since the individual signals cannot be separated.

    APIs that support signals can additionally accept base classes of their signal. postTask accepts an AbortSignal in addition to TaskSignal, and the API will even downcast a TaskSignal to an AbortSignal to support overriding priority:

    const controller = new TaskController('user-visible');
    const signal = controller.signal;
    
    function foo() {
      // Propagate cancellation from the parent task, but with a new fixed
      // priority. |signal| will be downcast to an AbortSignal since the API
      // supports it.
      scheduler.postTask(bar, {signal, priority: 'background'});
    }

    But this is limited. Since priority isn't separable, in order to propagate priority and abort separately — where both are bound to controllers — events are required.

    const controller = new TaskController('user-visible');
    const signal = controller.signal;
    
    function foo() {
      // Propagate priority from the parent task, but propagate abort 
      // separately. We can't use an AbortController here because we can't
      // separate the priority signal from |signal|.
      const subController = new TaskController(signal.priority);
    
      // Make |subController.signal| "follow" |controller.signal|.
      // (We could probably make this more ergonomic and explicit by adding a
      // follow() or join() method).
      signal.onprioritychange = () => { subController.setPriority(signal.priority); };
    
      // Do the same thing for cancellation, so that either controller can abort
      // the task. (We would need to do this regardless of having separate
      // signals.)
      signal.onabort = () => { subController.abort(); };
    
      scheduler.postTask(bar, {signal: subController.signal});
    }

    A downside in this example is that priority changes can come from either controller, but the developer only wants priority to be modified by the parent task's controller. And overriding the setPriority method doesn't help since it's needed in the event handler to manually propagate the priority. This is inherent to this approach and gets worse as more signals are conflated.

  3. What happens when APIs diverge? Much of the appeal of signals is that they can be shared between APIs. A TaskSignal can be used in any API that accepts an AbortSignal, which is a consequence of the inheritance chain. But what happens if one of these APIs changes and their signal diverges?

    For APIs that might also extend AbortSignal in different ways, passing a TaskSignal shouldn't be problematic since the APIs should still accept an AbortSignal for compatibility. They could optionally use the priority component of TaskSignal for prioritization if desired. The proposed FetchSignal falls into this category.

    The difficulty would come if signals share some signal component but it isn't represented in a linear hierarchy. For example, suppose that two APIs add a new signal to represent enabled/disabled (pausing) tasks:

    • API Foo, which already supports abort, extends AbortSignal adding enabled/disabled.
    • TaskSignal is is either extended, adding a new (implicit) signal to represent enabled/disabled tasks, or its class hierarchy must be changed (inherit from FooSignal, which may or may not make sense).
    • On the client side, more event wiring is needed for propagation.

    This situation might never arise, but I think it's worth considering how scalable the inheritance approach is.

Alternative Approaches

(See also the side-by-side comparison below for more examples, including signal propagation and integration with other APIs.)

Alternatively, the priority and abort components can be separated out. This comes in a number of flavors:

  1. Completely separate interfaces for new signals.
  • Create separate PriorityController and PrioritySignal interfaces
  • scheduler.postTask will take multiple signals, accepting either a map or individual named signals in its options dictionary
  • The individual AbortSignal is passed to existing APIs

Pros + Better conceptual fit + Simplifies propagation, or at least makes it clearer

Cons + No encapsulation + Poor ergonomics for the common case + Inconsistency: some APIs take a signal and others signals

Example:

const priorityController = new PriorityController('background');
const abortController = new AbortController();
const signals = {
  priority: priorityController.signal,
  abort: abortController.signal
};

scheduler.postTask(foo, {signals});

priorityController.setPriority('user-blocking');
abortController.abort();
  1. Separate signals and controllers, but TaskController is a composite of mixins provided for convenience
  • Extension of (1)
  • TaskController is a mixin of PriorityController and AbortController, bringing back some ergonomics for the common case.

Pros + Better conceptual fit and simplifies propagation (from (1)) + Good encapsulation for controller

Cons + Weak encapsulation for signals (map) + Poor ergonomcs for reading state + Inconsistency: some APIs take a signal and others signals

Example:

const taskController = new TaskController('background');
// This is still a map of signals, but both originate from |taskController|.
const signals = taskController.signals;

scheduler.postTask(foo, {signals});

taskController.setPriority('user-blocking');
taskController.abort();
  1. Separate signals and controllers, but TaskController and TaskSignal are composites of mixins provided for convenience
  • Extension of (2)
  • The API shape looks very similar to the current approach
    • scheduler.postTask takes a single signal, which can be a TaskSignal, or optionally an AbortSignal or PrioritySignal
  • TaskSignal would wrap individual signals
    • It should be constructible in order to combine signals
    • It would need to provide a way to get the underlying individual signals

Pros + Better conceptual fit and simplifies propagation (from (1)) + Encapsulation is probably as good as current approach

Cons + Ergonomics for propagation aren't quite as good as (1) and (2) + Existing API ergonomics: can't directly pass TaskSignal without modifying existing APIs since mixins aren't inheritance + Supporting the individual signal interfaces and needing to provide access to the indivdual signals feels a bit clunky?

Example:

// Composite controller for the simple case.
const taskController = new TaskController('background');

scheduler.postTask(foo, {taskController.signal});

taskController.setPriority('user-blocking');
taskController.abort();

// Create a task and only control its priority.
const priorityController = new PriorityController('background');
scheduler.postTask(foo, {signal: priorityController.signal});

Summary and Thoughts

  • The current approach is simple and ergonomic for the common case, but makes propagation less straightforward
  • Adding follow functionality would help, but doesn't solve propagating priority
  • The problem gets worse as more signals are added
  • Having a map of individual signals seems reasonable, but has two main issues:

    • Poor encapsulation
    • Naming difference with current APIs which take a single signal
      • Having some APIs take a signal and others signals seems quite bad
      • Maybe signal could just be a single signal or a map? This would be a path forward for existing APIs as well, although type checking would be required for the signal.
    • A composite TaskController helps and doesn't seem to have any downsides, but leaves poor encapsulation for signals.
  • Making TaskSignal a composite of mixins seems like it might be a reasonable compromise — it has the ergonomics of the current approach, but enables propagating individual signals, with better encapsulation than the signal-map approach.

    • The downside is compatibility with existing APIs, i.e. cannot pass a TaskSignal to other APIs for abort without API and IDL modification.
  • Also, it should probably be TaskSignals (plural) or TaskState or something?
  • There should be a (future) path forward from the existing approach to the composite controller/signal approach as long as solve the compatibility issue (use TaskSignal as an AbortSignal)

Appendix

Side-by-side Comparison

  • Option 1: Current approach (TaskSignal extends AbortSignal)
  • Option 2: No TaskSignal or TaskController, only Priority[Signal|Controller] and Abort[Signal|Controller]
  • Option 3: Separate PrioritySignal, TaskController is a composite of mixins
  • Option 4: Separate PrioritySignal, TaskController and TaskSignal are composites

Example 1: Controlling tasks and reading state.

// TaskSignal extends AbortSignal.
function option1() {
  const controller = new TaskController('background');
  const signal = controller.signal;

  scheduler.postTask(foo, {signal});

  // Current state.
  console.log(signal.priority);
  console.log(signal.aborted);

  controller.setPriority('user-visible');
  controller.abort();
}

// No separate TaskController or TaskSignal.
function option2() {
  const abortController = new AbortController();
  const priorityController = new PriorityController('background');
  const signals = {
    abort: abortController.signal,
    priority: priorityController.signal
  };

  scheduler.postTask(foo, {signals});

  // Current state.
  console.log(signals['priority'].priority);
  console.log(signals['abort'].aborted);

  priorityController.setPriority('user-visible');
  abortController.abort();
}

// TaskController is a composite, separate AbortSignal and PrioritySignal.
function option3() {
  const controller = new TaskController('background');
  const signals = taskController.signals;

  scheduler.postTask(foo, {signals});

  // Current state. This is the same as option 2 since only the controllers are
  // mixins.
  console.log(signals['priority'].priority);
  console.log(signals['abort'].aborted);

  // Controlling tasks is identical to option 1 since TaskController mixes in
  // functionality from other controllers.
  controller.setPriority('user-visible');
  controller.abort();
}

// TaskController and TaskSiganal are composites, using mixins, not inheritance.
//
// Omitted. This is identical to option 1.
function option4() {}

Example 2: Interacting with other async APIs.

// We'll use fetch here since it takes an AbortSignal.

// TaskSignal extends AbortSignal.
function option1() {
  const controller = new TaskController('background');
  const signal = controller.signal;

  scheduler.postTask(foo, {signal});

  // |signal| is downcast to an AbortSignal.
  fetch(url, {signal});

  // Abort the fetch and task.
  controller.abort();
}

// No separate TaskController or TaskSignal.
function option2() {
  const abortController = new AbortController();
  const priorityController = new PriorityController('background');
  const signals = {
    abort: abortController.signal,
    priority: priorityController.signal
  };

  scheduler.postTask(foo, {signals});

  // We need to pluck out the AbortSignal and explicitly pass it.
  fetch(url, {signals['abort']});

  abortController.abort();
}

// TaskController is a composite, separate AbortSignal and PrioritySignal.
function option3() {
  const controller = new TaskController('background');
  const signals = taskController.signals;

  scheduler.postTask(foo, {signals});

  // We need to pluck out the AbortSignal here as well and explicitly pass it.
  fetch(url, {signals['abort']});

  controller.abort();
}

// TaskController and TaskSiganal are composites, using mixins, not inheritance.
function option4() {
  const controller = new TaskController('background');
  const signal = taskController.signal;

  scheduler.postTask(foo, {signal});

  // Unlike with the previous example, this is not identical to option 1.
  // JavaScript does not support multiple inheritance, so TaskSignal cannot
  // be both an AbortSignal and PrioritySignal. So, we either need to
  //  (a) pluck out the abort component, or
  //  (b) modify fetch to accept a TaskSignal for abort
  fetch(url, {signal.signals['abort']}); // or signal.abortSignal, etc.

  controller.abort();
}

Example 3: Independent signal propagation.

// Propagate priority from a parent task's signal, and propagate abort
// independently, but following the parent task's abort as well.

// TaskSignal extends AbortSignal.
function option1(parentSignal) {
  // Need to make sure this starts with the |parentSignal|'s priority.
  const myController = new TaskController(parentSignal.priority);

  // Wire things up.
  parentSignal.addEventListener('abort', () => {
    myController.abort();
  });
  parentSignal.addEventListener('prioritychange', () => {
    myController.setPriority(parentSignal.priority)
  });

  const mySignal = myController.signal;

  scheduler.postTask(foo, {signal: mySignal});
}

// No separate TaskController or TaskSignal.
function option2(parentSignals) {
  // We don't need to change priority, just need to abort.
  const myController = new AbortController();

  // Wire things up.
  parentSignals['abort'].addEventListener('abort', () => {
    myController.abort();
  });

  const mySignals = {
    abort: myController.signal,
    priority: parentSignals['priority']
  }

  scheduler.postTask(foo, {signals: mySignals});
}


// TaskController is a composite, separate AbortSignal and PrioritySignal.
//
// Omitted. This is identical to option 2 since we don't need a full
// TaskController, just an AbortController.
function option3(parentSignals) {}

// TaskController and TaskSiganal are composites, using mixins, not inheritance.
function option4(parentSignal) {
  // We don't need to change priority, just need to abort.
  const myController = new AbortController();

  // Wire things up.
  parentSignal.signals['abort'].addEventListener('abort', () => {
    myController.abort();
  });

  // Create a new TaskSignal that is a composite of different signals.
  // There might be a better way to do this?
  const mySignal = new TaskSignal({
    abort: myController.signal,
    priority: parentSignals['priority']
  });

  scheduler.postTask(foo, {signal: mySignal});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment