question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

RFC: improved task linkage semantics

See original GitHub issue

This RFC is not “complete” in that it doesn’t yet offer a concrete proposal, but I wanted to put it out there and start collecting feedback

Summary

In ember-concurrency, when a parent task is cancelled, it cancels any child tasks it is currently yielding. This is desirable behavior we should continue to support, but the issue is that this linkage occurs at yield-time rather than at perform-time which causes a variety of subtle issues. The purpose of this RFC is to discuss alternatives that would solve a few ugly corner cases and bring e-c more in line with classic paradigms for process tree management.

Motivation

Here is the canonical example of a parent task performing a child:

parentTask: task(function * () {
  yield this.childTask.perform();
}),

The above example results in the parentTask being “linked” to the child in such a way that if the parent task is cancelled (explicitly or implicitly via .restartable()), the cancellation will cascade into the linked child task and cancel the child task.

What if there’s a gap between when you perform a task and when you yield it? Consider the following

parentTask: task(function * () {
  // perform `childTask`, which starts running immediately
  let taskInstance = this.childTask.perform();

  // do some other async before you actually "await" the task instance...
  yield timeout(5000);

  // "await" the task instance (linkage occurs here)
  yield taskInstance;
}),

There is a “gotcha” with the above example: parentTask doesn’t get linked to childTask until the yield taskInstance, so if parentTask is cancelled (or errors) before yielding the child task instance, the child task will continue to execute. The result of this is an inconsistent cleanup of the task/process tree depending on when you cancel, which leads to very subtle race-conditiony bugs.

The likelihood of encountering these cases increases if you start using (the e-c variants of) Promise.all/race/etc; often you need to do some setup of multiple child tasks before you’re ready to await all of them, which expands the window of unlinked-ness.

If ember-concurrency made you decide, at perform-time, whether the child task should be linked to the parent task, a lot of these issues would go away. EC actually has an API for this, but people don’t really know about it unless they get console.log()s alerting them about the dreaded self-cancel loop footgun.

parentTask: task(function * () {
  // perform childTask and link it to parentTask
  let taskInstance = this.childTask.linked().perform();

  // do some other async before you actually "await" the task instance...
  yield timeout(5000);

  // "await" the task instance
  yield taskInstance;
}),

There’s also an API for marking the perform as unlinked() to prevent linkage when the task instance is yielded:

parentTask: task(function * () {
  yield this.childTask.unlinked().perform();
}),

If parentTask is cancelled in the above example, childTask will continue to run.

While it’s nice that these escape hatches exist, ember-concurrency’s defaults are non-ideal and can lead to surprisingly behavior. The purpose of this RFC is to propose changing the defaults. Specifically:

  1. The only thing yielding a task instance should do is await it to run to completion. It should not cause linkage.
  2. By default, performs should be unlinked (or perhaps we get rid of perform as a word and always make you choose performLinked or performUnlinked). In other words, for something to be linked, you must see reference to the word link in the code.

This would definitely be a breaking change, but I consider this to be one of the main issues that make me hesitant to propose that ember-concurrency be a part of Ember.js Core.

Detailed design

How to access parent task instance?

Any proposed API for making task linkage explicit has to solve the problem of “how do I get a reference to the parent task instance that I am linking to?”

Current .linked() approach

e.g. this.childTask.linked().perform()

One ugly aspect of this API is that it relies on asking ember-concurrency what the current running task instance is which is essentially a global variable that ember-concurrency maintains as it executes task instances. I’m not sure how big a deal this is, but it’s definitely… weird. I’m not exactly sure how/whether this API would troll people, but here’s an example of improper use:

parentTask: task(function * () {
  later(() => {
    this.childTask.linked().perform();
    // ERROR: "You can only call .linked() from within a task."
  }, 500);
}),

This would need to be rewritten as:

parentTask: task(function * () {
  let linkedHandle = this.childTask.linked()
  later(() => {
    linkedHandle.perform();
  }, 500);
}),

(Aside: it should be noted that if the parent task instance is cancelled before the timer elapses, linkedHandle.perform() will essentially no-op: it’ll create/return a task instance that is immediately cancelled.)

Explicitly fetching/passing parent task instance

We could make it so that .linked() accepts a TaskInstance rather than looking up the global current running task instance. We could also make a .performLinked method that accepts the parent task instance as the first arg, e.g.:

parentTask: task(function * () {
  let currentInstance = /* TODO some API for fetching the running task instance */;
  this.childTask.linked(currentInstance).perform();
  // or
  this.childTask.performLinked(currentInstance);
}),

I don’t have strong opinions as to whether we provided performLinked vs .linked(...).perform(), but there’s still the issue of how to fetch the current running instance.

Note that encapsulated tasks don’t have this issue since the value of this within the *perform() is the task instance:

parentTask: task({
  *perform() {
    this.owner.childTask.performLinked(this);
  }
}),

But for classic tasks, the only feasible API for fetching the parent task instance would be to yield a “special command” to e-c:

import { task, getCurrentTaskInstance } from 'ember-concurrency';

parentTask: task(function * () {
  let currentTaskInstance = yield getCurrentTaskInstance;
  this.childTask.performLinked(currentTaskInstance);
}),

This approach might surprise people, as generally speaking if you’re using e-c, yield always means “await the following async thing”. But we can also use yield to issue special commands to the e-c task runner, and getCurrentTaskInstance would be a special command that immediately returns a reference to the current running task instance.

CancellationTokens

Regardless of which approach we settle on for accessing the parent task instance to link to, we can think of the argument that you pass to .linked() as kind of CancellationToken, which is a common abstraction for managing task/process trees as well as a concept in various proposals to add cancellable promises to JavaScript.

Here is an example of how cancel tokens might look/work with async functions:

async function doStuff(arg1, arg2, cancelToken) {
  await uninterruptibleWork();
  cancelToken.cancelIfRequested();
  await interruptibleWork(cancelToken);
  await uninterruptibleWork();
  cancelToken.cancelIfRequested();
}

Basically, the caller is responsible for constructing/providing a cancelToken to doStuff. The caller can request cancellation at any time on the cancelToken, but the async function ultimately decides when it’s a good time to cancel via called .cancelIfRequested() at discrete times. Whereas ember-concurrency tasks are assumed to be cancellable at any point, async functions are uncancellable by default, and you must “sprinkle in” cancellability where you need it using cancel tokens.

The story for “linkage” in ember-concurrency should probably factor in CancellationTokens. Perhaps it makes sense that .linked() / .performLinked() should also work with cancel tokens, such that if you do .linked(cancelToken).perform(), you have the same guarantees as if you’d linked to a parent task: the moment cancellation is requested on the token, the ember-concurrency task will immediately cancel.

Additionally, it should be possible to convert a task instance into a CancellationToken for interop with async functions, e.g.:

import { task, getCurrentTaskInstance } from 'ember-concurrency';

parentTask: task(function * () {
  let currentTaskInstance = yield getCurrentTaskInstance;
  this.doStuff('a', 'b', currentTaskInstance.asCancelToken());
}),
async doStuff(arg1, arg2, cancelToken) {
  // ...
}

Error handling?

How should we handle the following case:

parentTask: task(function * () {
  let me = yield getCurrentTaskInstance;

  // perform linked tasks but don't await them
  let a = this.childTask.performLinked(me);
  let b = this.childTask.performLinked(me);
  let c = this.childTask.performLinked(me);

  yield timeout(10000)
}),

What happens if one of the child task instances errors? Should parentTask immediately cancel? Should it only cancel if it is yielding a childTask?

I’d made a decision long ago that if you yield a child task that gets cancelled, the cancel bubbles up in a way that is un-catch-able (internally this is achieved by returning from the generator function rather than throwing). I made the change because at the time if you wanted to catch errors thrown from perform tasks, you had to check whether the error was a “TaskCancellation”, e.g.

parentTask: task(function * () {
  try {
    yield this.childTask.perform();
  } catch(e) {
    if (isTaskCancellation(e)) {
      return; // disregard
    } else {
      // an actual error occurred; do stuff.
    }
  }
}),

With the change, you don’t have to write the isTaskCancellation guards all over the place, but you also lose any way to treat an expected child task cancellation as an error. We should revisit this decision as we consider how to handle performLinked-but-not-awaited getting unexpectedly cancelled.

Perhaps we can draw some inspiration from Erlang:

The default behaviour when a process receives an exit signal with an exit reason other than normal, is to terminate and in turn emit exit signals with the same exit reason to its linked processes. An exit signal with reason normal is ignored.

A process can be set to trap exit signals by calling: process_flag(trap_exit, true)

When a process is trapping exits, it does not terminate when an exit signal is received. Instead, the signal is transformed into a message {‘EXIT’,FromPid,Reason}, which is put into the mailbox of the process, just like a regular message.

An exception to the above is if the exit reason is kill, that is if exit(Pid,kill) has been called. This unconditionally terminates the process, regardless of if it is trapping exit signals.

This makes sense for Erlang, but ember-concurrency task instances don’t (currently) have any concept of mailbox / event queue or a receive loop, so this wouldn’t really fall neatly into ember-concurrency’s model. Perhaps we need some additional utilities to catch/report on the status of child tasks, but I’m not sure what?

How we teach this

TODO

Drawbacks

TODO

Alternatives

TODO

Unresolved questions

TODO

Issue Analytics

  • State:open
  • Created 5 years ago
  • Comments:15 (11 by maintainers)

github_iconTop GitHub Comments

1reaction
machtycommented, Oct 1, 2018

@martletandco It’s off-topic enough to save discussion for another RFC (or the Discord channel) but here are some sketches of e-c process APIs

I’m not sure what the use cases are for linkage after perform but we can always add it in later if we need it. I guess we can start to deprecate unqualified .perform()s over time in favor of .performLinked() or .performUnlinked().

1reaction
machtycommented, Sep 30, 2018

@happycollision Your explanation makes sense, but makes me think we should consider improved/simpler mental models to avoid requiring that you think through every possible cancellation corner case. In other words, it’s unfortunate that seasoned e-c users must have a fear of yield as a possible cancellation point, as it clutters thinking, hampers refactoring, and leads to difficult-to-test regressions whenever you make modifications to a task that at one point was delicately designed to avoid certain sequences of yields that might feasibly lead to an invalid state.

I don’t know what that better mental model is but if we’re thinking in terms of 1.0.0 release goals, I’d like to address any common cases where e-c requires developers to have eternal vigilance to avoid race conditions.

It’s pretty unlikely that you would encounter the situation you described in actual production code, but I believe something like this would reproduce it:

someTask: task(function * () {
  this.restartableChildTask.perform();
  this.restartableChildTask.perform();
  this.restartableChildTask.perform();
}),
restartableChildTask: task(function * () {
  let currentTaskInstance = yield getCurrentTaskInstance;
  yield doImportantWork(currentTaskInstance.asCancellationToken());
}).restartable(),

I believe this would result in all restartableChildTask instances, except for the last, to fail to doImportantWork; internally, the getCurrentTaskInstance would immediately produce a value, but e-c wouldn’t continue executing until the actions run loop queue, which would happen after the other this.restartableChildTask.perform()s.

This is a rare and contrived situation that probably points to something else that “off” about your code (e.g. calling restartable tasks multiple times in a row and depending of the e-c behavior of synchronously executing the first chunk of a task). But aside from this very unusual corner case, I believe there’s a more common need for some ability to mark regions of a task as uncancellable/uninterruptible, which would solve this use case AND other use cases of actual async (and not the short-term run loop “async” that completely resolves within one javascript event loop tick). Maybe we add a task modifier called .uninterruptible() which automatically prepends (pre-curries) the task instance to the arguments so that it can be used to signal when the task instance becomes interruptible again.

someTask: task(function * () {
  this.restartableChildTask.perform();
  this.restartableChildTask.perform();
  this.restartableChildTask.perform();
}),
restartableChildTask: task(function * (currentTaskInstance) {
  yield doImportantWork(currentTaskInstance.asCancellationToken());
}).uninterruptible(),

It might be a weird unexpected side effect that .uninterruptible() would start providing the task instance as the first arg, but maybe not? I dunno.

That all said…

For the purposes of this RFC, I’m still thinking it’s overkill that we require passing the parent task instance to childTask.linked().perform() when we can automatically derive it with 99.999% confidence. The only time it seems useful would be for async function interop.

Read more comments on GitHub >

github_iconTop Results From Across the Web

HTTP Semantics RFC 9110 - IETF Datatracker
HTTP/1.1 was designed to refine the protocol's features while retaining compatibility with the existing text-based messaging syntax, improving its ...
Read more >
T18691 RFC: Section header "share" link
This RFC aims to make it easier for readers to share links to sections on wiki ... Headings that are produced by <h2>...
Read more >
Hypertext Transfer Protocol - Wikipedia
The Hypertext Transfer Protocol (HTTP) is an application layer protocol in the Internet ... Development of early HTTP Requests for Comments (RFCs) started...
Read more >
[Pre-RFC] Another take at clarifying `unsafe` semantics
For this reason, I would like to propose here a purposely minimal and iterative syntax evolution, which I think would better fit Rust's ......
Read more >
An Introduction to Semantic Networking
The intent is to facilitate enhanced routing/forwarding decisions based on these ... Internet-Drafts are working documents of the Internet Engineering Task ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found