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:
- Extend existing signals/controllers through inheritance
- 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.
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();
-
Encapsulation: All of the task operations and state are encapsulated in
TaskController
andTaskSignal
, respectively -
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.
-
Familiar API Shape: The API shape looks a lot like existing APIs that use
AbortSignal
, likefetch
-
Compatibility with existing APIs: Since
TaskSignal
is anAbortSignal
, one can be used in APIs that accept anAbortSignal
-
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. -
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 anAbortSignal
in addition toTaskSignal
, and the API will even downcast aTaskSignal
to anAbortSignal
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. -
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 anAbortSignal
, 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 aTaskSignal
shouldn't be problematic since the APIs should still accept anAbortSignal
for compatibility. They could optionally use the priority component ofTaskSignal
for prioritization if desired. The proposedFetchSignal
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, extendsAbortSignal
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 fromFooSignal
, 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.
- API
(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:
- Completely separate interfaces for new signals.
- Create separate
PriorityController
andPrioritySignal
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();
- Separate signals and controllers, but
TaskController
is a composite of mixins provided for convenience
- Extension of (1)
TaskController
is a mixin ofPriorityController
andAbortController
, 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();
- Separate signals and controllers, but
TaskController
andTaskSignal
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 aTaskSignal
, or optionally anAbortSignal
orPrioritySignal
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});
- 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 otherssignals
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.
- Having some APIs take a
- 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.
- The downside is compatibility with existing APIs, i.e. cannot pass a
- Also, it should probably be
TaskSignals
(plural) orTaskState
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 anAbortSignal
)
- Option 1: Current approach (
TaskSignal
extendsAbortSignal
) - Option 2: No
TaskSignal
orTaskController
, onlyPriority[Signal|Controller]
andAbort[Signal|Controller]
- Option 3: Separate
PrioritySignal
,TaskController
is a composite of mixins - Option 4: Separate
PrioritySignal
,TaskController
andTaskSignal
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});
}