Skip to content

AsapScheduler race condition w/ nested schedules (Cannot read properties of undefined (reading 'execute')) #6672

Closed
@matthewjh

Description

Bug Report

Current Behavior
On our non-trivial, non-public UI codebase that heavily uses rx.js, we sporadically see the following error arising from code that uses the AsapScheduler:

TypeError: Cannot read properties of undefined (reading 'execute')
at AsapScheduler.flush (node_modules/rxjs/src/internal/scheduler/AsapScheduler.ts:19:8

This is thrown from

if ((error = action.execute(action.state, action.delay))) {

I have debugged this in-situ and have arrived at what is, I think, a working understanding how the exception arises. I believe it to be a problem with nested AsapTask schedules.

Note there have been reports of similar issues in #4690 and #2697, which were closed without resolution. I think it likely all these share this cause.

  1. Let's say an AsapAction, A, is scheduled. The scheduler's actions are [A]. When A is executed, it will schedule another AsapAction, B.
  2. When the scheduler asynchronously flushes, it first sets this._scheduled = undefined;
    https://github.com/ReactiveX/rxjs/blob/master/src/internal/scheduler/AsapScheduler.ts#L5
  3. Then it iterates through the actions and executes them. As A is the only action, it will call action.execute() on A. It shifts A off so the actions queue is now [].
    (Here, index is initially -1 and count is the length of the queue on entering flush, which is 1.)
 do {
      if ((error = action.execute(action.state, action.delay))) {
        break;
      }
    } while (++index < count && (action = actions.shift()));
  1. In the execution of A, A schedules AsapAction B (as mentioned). The action queue is now [B]. As asapScheduler._scheduled was set to undefined on entering AsapScheduler#flush, the following code will create a new async task that will flush the scheduler:

https://github.com/ReactiveX/rxjs/blob/master/src/internal/scheduler/AsapAction.ts#L21

  1. On returning from action.execute on A, the while expression will be evaluated. Bear in mind that we mutated actions in (4). The first part is true: (++index < count) === (++(-1) < 1) === (0 < 1) === true
    The second part, action = actions.shift() will pop B from the actions queue and set the current action to B.
  2. The next turn of the loop will call action.execute() on B.
  3. Later on, the JS VM will come to execute the async task that was created when B was originally scheduled, in (4). The purpose of this task is to flush any actions created between B and now.
  4. However, assuming no AsapActions were scheduled after B, this flush task is now redundant as B has already been executed and popped from the actions queue.
  5. The async task enters flush, and because actions is [], action = action || actions.shift()!; is undefined.
  6. The exception is then thrown on action.execute(action.state, action.delay).
TypeError: Cannot read properties of undefined (reading 'execute')
            at AsapScheduler.flush (node_modules/rxjs/src/internal/scheduler/AsapScheduler.ts:19:8

One interesting footnote is that in our codebase, it appears to be quite common for an AsapAction to schedule another AsapAction. But - this exception is rare. The question is why. I suspect the following code serves to mask the issue in most instances, because it happens to remove the async task if the action queue is empty:

https://github.com/ReactiveX/rxjs/blob/master/src/internal/scheduler/AsapAction.ts#L23

// If the scheduler queue is empty, cancel the requested microtask and
    // set the scheduled flag to undefined so the next AsapAction will schedule
    // its own.
    if (scheduler.actions.length === 0) {
      immediateProvider.clearImmediate(id);
      scheduler._scheduled = undefined;
    }

However, as far as I can tell (not familiar with this code), if this happens to clean up dangling async tasks created by this scenario, it would be coincidence - they are logically distinct code paths.

Expected behavior
No exception

Reproduction

** Please provide repro code does not involves framework, or other setups to address this bug is specific to rxjs core **

  • REPL or Repo link:
    I don't have one at the moment but will try to make a stackblitz or failing test case on an rxjs fork.

Environment

  • Runtime: Chrome 91
  • RxJS version: 6.5.5
  • (If bug is related) Loader, build configuration: [e.g webpack, angular-cli version, config]

Possible Solution
The simplest fix is to fix the flawed assumption in AsapScheduler flush that there will always be actions in the queue. As the reports of this exception demonstrate, this is not always true. However, it would be even better in this case to not schedule the flush at all.

I'm assuming nested AsapAction schedules should indeed be flushed in the same turn, similar to microtasks. In that case, I think there is an argument that asapScheduler._scheduled should not be set to undefined until after all the actions are executed in the flush loop. asapScheduler._scheduled is what drives the "do I need to schedule another flush?" condition (https://github.com/ReactiveX/rxjs/blob/master/src/internal/scheduler/AsapAction.ts#L21). If asapScheduler._scheduled? answers the question "is there already a time in the future in which my new action will be executed?", then the answer needs to be "yes" inside the action execution loop. It is logically inconsistent to set this to undefined at the start of flush, answering "no" to the question, when that is not quite true: actions scheduled inside the action execution loop will be executed in the current flush.

That solution would look like this:

export class AsapScheduler extends AsyncScheduler {
  public flush(action?: AsyncAction<any>): void {
    this._active = true;
   // remove    
   // this._scheduled = undefined;

    const { actions } = this;
    let error: any;
    let index = -1;
    action = action || actions.shift()!;
    const count = actions.length;

    do {
      if ((error = action.execute(action.state, action.delay))) {
        break;
      }
    } while (++index < count && (action = actions.shift()));

    // insert
    this._scheduled = undefined;
    this._active = false;

    if (error) {
      while (++index < count && (action = actions.shift())) {
        action.unsubscribe();
      }
      throw error;
    }
  }

However, I'm not sure whether there are reasons NOT to do this.

From looking at one of the linked issues, as well as the code, it looks like other schedulers may suffer from the same loophole (e.g. the RAF scheduler).

Additional context/Screenshots
Add any other context about the problem here. If applicable, add screenshots to help explain.

@cartant @notnarb @decafdennis

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions