What constitutes a run loop side effect?

These articles were a really helpful guide in my understanding about how async side effects in JS impact my application. I think I’ve got a working understanding of the benefits of the run loop, but I’m still confused on what’s considered a side effect and how to track down which parts of my async code result in side effects.

Example here. I have an async “save” action in one of my controllers. It kicks off a batch save like so.

import { all } from 'rsvp';

async save(foos) {
    let promises = foos.map(async (foo) => {
      // ...
      await foo.get('bar').save();
      // ...
      foo.get('baz').someFunc();
      foo.set('isEditing', false);
      // Kicking off an ember-concurrency task. Not waiting for it.
      this.get('someTask').perform(foo);
      // ...
      return foo;
   });

    try {
      await all(promises);
      // Show success message.
    } catch (err) {
      // Show error message.
    }
}

For reasons I do not understand, foo.get('baz').someFunc(); needs a run() loop, but the other lines don’t. Based on my understanding of the run loop, I would assume that all of them need a run(), but that does not seem to be the case.

I need to do this…

run(() => {
  foo.get('baz').someFunc();
});
foo.set('isEditing', false);
// Kicking off an ember-concurrency task. Not waiting for it.
this.get('someTask').perform(foo);

…otherwise, I get this error…

You have turned on testing mode, which disabled the run-loop’s autorun. You will need to wrap any code with asynchronous side-effects in a run` error

So someFunc() causes a side effect and needs a run(). That makes sense. But what about those other lines? Why do they not need a run()? Aren’t those side-effects too? I use {{foo.isEditing}} in the template for this controller. Aren’t async updates that impact the view considered side effects?

In fact, there’s actually a lot of things that can have asynchronous side effects, even calling “Ember.object.set” on a property bound to a template.

What am I missing here? Why does Ember think that one of these async operations warrants a run() but the others don’t? Is there a definitive list somewhere of what sort of async behavior is and is not considered a side effect? Is it “everything”? If so, why are some of my async modifications not throwing an assertion while others are?

5 Likes

Had you looked at the transpiled code? I’m not sure right now, but async/await can be transpiled and the resulting code be the source of the problem.

Concretely, it’s anything that tries to schedule more work on the runloop. But that is not a very helpful way to think about it, since it’s not practical to know or care what other code is scheduling things. You wouldn’t want your code to break in the future if some other method you’re calling adds a call to schedule or later. So you should just always make sure you’re staying inside a run loop.

Since you are using ember-concurrency, I would suggest using that consistently throughout your app and not trying to mix it with async functions. If you stick to ember-concurrency, it will make sure there is always a runloop when resuming an asynchronous task so you never need to think about it. For example, your save async function could become an ember-concurrency task. All the awaits just switch to yields.

We are very close to being rid of the autorun assertion forever. I will be extremely glad to be rid of it. It was necessary before all our supported browsers had true microtask scheduling, but now they all do.

11 Likes

Thanks @ef4. That clears things up quite a bit for me.

I had a sneaking suspicion that ember-concurrency was “run loop aware” (for lack of a better description) like you describe.

If you stick to ember-concurrency, it will make sure there is always a runloop when resuming an asynchronous task so you never need to think about it.

But it’s interesting to me that nowhere in the ember-concurrency docs or API does it seem to mention that helpful behavior (unless I’m missing it. I did a simple ctrl + F on “loop” in each page). They mention “loop” regarding polling task examples and using waitForQueue, but I think those docs are lacking a nice big “This handles run() concerns for you!” I wonder if that’s purposefully left out of the documentation.

The docs seem to explain in detail that tasks are helpful because they provide a lot of the boilerplate we look for in async code, and that they are cancellable, but nowhere could I find the docs explicitly mention the run loop. I’d definitely think this behavior of tasks is valuable to add to the list of benefits.

Thanks for clearing that up, because switching to tasks like you suggest solved my problems and seems much more predictable from my perspective!

1 Like

I expect that it is. For the most part, we don’t really want users to have to care quite so much about being “run loop aware”.

1 Like

That makes sense @rwjblue. :+1:

For the sake of completeness, I’ll roughly describe my user path/the thinking I took to get here in case it’s helpful.

"I should switch to async/await! It’s all the rage these days and I prefer the syntax. Fare thee well, promises! I’ll start by converting this save() action I’ve got in my controller.

(refactors save() to an async function)

Uh oh, I’m seeing the run() loop assertion in my tests now. Rats!

(reads a bunch of articles)

I must be causing unintended side effects now that my action is async!

Hmm, I wonder if ember-concurrency does anything special to handle async code and the run() loop? Hmm, nothing in the docs about it, so I guess not. Darn!

I may as well keep using async on my action then. No reason to switch this to a task since it wouldn’t provide any special assistance for my side effects issue, and I do not need any of the other conveniences provides by tasks. No benefits to my using task() here! But I should post a question on the forums to try and understand the behavior I’m seeing."

Clearly my assumption about ember-concurrency in relation to the run() loop was wrong, and I think I was lacking a lot of understanding in general around best practices/designs for async programming in JS.

This whole experience taught me a lot about how I think of my async code and the run() loop, so it was a worthwhile lesson!

1 Like

Yep, that totally makes sense, and thank you for writing down your thought process (it really is helpful)!

As @ef4 mentioned, we are working to remove that auto-run assertion (hopefully by the time 3.3 or 3.4 rolls around we will have landed that). In the meantime, using async / await in application code is pretty annoying (largely because of the auto-run assertion).

For example:


// app/components/gist-edit.js
export default Component.extend({
  actions: {
    async update() {
      // using native `fetch` here (not ember-fetch)
      await fetch('https://api.github.com/gists', {
        method: 'post',
        body: JSON.stringify({ /* snip */ })
      })

      this.set('flash-alert', 'successfully updated!');
    }
  }
});

To walk this through:

  1. Ember automatically invokes the update action itself within a run-loop
  2. The fetch is kicked off
  3. When the fetch is finished the previously started run-loop is long gone
  4. The this.set('flash-alert' runs
  5. Setting any property that is rendered in the DOM, schedules a re-render
  6. :boom: auto-run assertion: cannot schedule when not within a run loop
  7. :sob: :cry: :crying_cat_face: :disappointed:
2 Likes

That totally makes sense. Looking forward to that update dropping in a future release!

Thanks!

Curious about this. Is there any PR, Issues or RFC I could follow that will remove the auto-run assertions?

1 Like

I have an unpublished RFC draft, but still need to do more research to ensure consistent flush semantics at arbitrary microtask depth. Some of the work described in that draft’s Detailed Design section has already been done (step 1 and 2) and is included in Ember 3.2.0 betas (though could be removed if significant issues are discovered)…

1 Like

Okay, so I think I have a working understanding of how things happen re: Run Loop. But recently I had some tests failures (on 3.2 beta) that have me wondering about the behavior; specifically related to set.

When does Ember know to schedule async work for a set vs when it can do that work sync?

Great question! I had an answer originally drafted, but in gathering the steps I realized that my mental model was slightly off.


Time for a code safari :zebra:!

// assume obj here is _something_ :raised_hand: :wave:  
Ember.set(obj, 'foo', 'bar');

When Ember.set runs, the following path is taken:

  1. Ember.set checks if foo were a computed property on obj (assume for now that it isn’t) – here
  2. Ember.set checks if obj.setUnknownProperty exists (assume that it isn’t) – here
  3. Ember.set checks if foo is a dependent key in an observer / computed or is otherwise “watched” (e.g. due to template usage) (assume that it isn’t) – here
  4. Ember.set does obj.foo = 'bar'here
  5. If the value is different that the previous value, Ember.set calls notifyPropertyChange(obj, 'foo')here
  6. notifyPropertyChange checks if the property is being watched, and notifies any observers or computed properties that foo has changed – here
  7. notifyPropertyChange then “marks the object as dirty” – here
  8. markObjectAsDirty grabs the underlying “reference” / “tag” for that object and property then marks it as dirty – here
  9. If there was a tag, markObjectAsDirty calls ensureRunLoop to ensure a runloop is scheduled – here
  10. ensureRunLoop checks if anything has been rendered, and if it has schedules a run loop

Footnotes:

  • markObjectAsDirty is an internal utility function primarily used with Glimmer 2’s reference system

tldr; The basic decision tree for when an Ember.set would schedule work to happen async is:

  • Has anything been rendered yet? If not, then Ember.set currently will not schedule any async.
  • Is the new value being set different than the old value? If not, then nothing is notified or scheduled.
  • Has the object (if an ObjectProxy) or property (for normal objects) been rendered in a template? If not, nothing is scheduled.

If the answers to all of those questions are “yes”, then async will be scheduled…

Phew, sorry about that (it was fun though, huh?)

6 Likes

Thanks Rob. The only thing I would elaborate on top is to point out that everything stops at step 6 if the value is not being watched. And there are several cases where that has practical impact.

For example, we generally don’t start observing things on an object until after init has finished, which means that if you call set on the object during construction you won’t start generating async work.

For another, in a component you can say this.something = 1 and Ember will not complain. But if you later put {{something}} into the template, Ember will complain (in development mode) that you should have used set. The difference is that putting it into a template causes it to be watched.

4 Likes

FWIW, I discovered while researching for my last post that my comment in this post wasn’t quite correct.

Specifically, the async that schedules a future re-render when a rendered property changes will not trigger an auto-run assertion. The ensureRunLoop function that I mentioned in Step 10 intentionally avoids the assertion.

Therefore the following code would not trigger the autorun assertion.

However, changing that this.set to something else that calls through Ember.run.schedule would assert.

For example:

    async update() {
      // using native `fetch` here (not ember-fetch)
      await fetch('https://api.github.com/gists', {
        method: 'post',
        body: JSON.stringify({ /* snip */ })
      })

      this.get('router').transitionTo('some-route');
    }
3 Likes

Okay reading all this, I’ve come to the conclusion to me that all these run-loop side effects is due to this.set. Now I’m looking into overwriting the some DS.Adapter methods and I looked at this guide. The snippet code wraps the result of the $.ajax in a run-loop. I’m assuming that the run-loop in that snippet is unnecessary as the caller of the adapter.createRecord should be the one wrapped in a run-loop if it does any this.set afterwards.

Is this right?

Not exactly. We definitely would not want everyone who ever calls set to also need to call run or run.join. That would be pretty bad.

Instead, we want the vast majority of code to be able to take for granted that it’s already being managed by the runloop, and not have to think about it.

The appropriate time to call run is when first entering Ember code. Most of the time that happens automatically, like when an app is rendering for the first time, or when Ember is handling an event. But sometimes you are explicitly scheduling a call into Ember code from some other source, and that is when you need to use run.

In the snippet you linked, the other source is jQuery. When jQuery calls us back, it will be doing so with no other Ember stuff higher on the call stack, so our callbacks are the entry point back into Ember, so that’s a good place to have run.

2 Likes

Interesting. It’s confusing for me since I believe RSVP properly wraps its resolves in a run-loop and in the snippet I posted, the RSVP wouldn’t resolve until the $.ajax completes.

Right now, I just want to make sure that you took that into consideration and that the explanation you’ve provided still fits that scenario.

Thanks for the reply! :slight_smile:

1 Like

I did go “huh” when I was thinking about RSVP.Promise in that example, because you’re right that RSVP (when used in ember) is runloop aware.

But I tested it and you still need run in the example or you will get autorun assertions when running in testing mode.

RSVP does know to schedule the resolve or reject work onto the run loop, but it expects to be doing that inside some existing runloop. Basically it’s good at keeping work inside the loop, but it can’t unilaterally make a new runloop for the same reason that any schedule cannot.

We are so close to being able to kill the autorun assertion and make all of this not matter at all. I really want to see that happen.

4 Likes