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
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.
- Let's say an
AsapAction
, A, is scheduled. The scheduler's actions are[A]
. When A is executed, it will schedule anotherAsapAction
, B. - When the scheduler asynchronously flushes, it first sets
this._scheduled = undefined;
https://github.com/ReactiveX/rxjs/blob/master/src/internal/scheduler/AsapScheduler.ts#L5 - Then it iterates through the actions and executes them. As A is the only action, it will call
action.execute()
on A. Itshifts
A off so the actions queue is now[]
.
(Here,index
is initially -1 and count is the length of the queue on enteringflush
, which is 1.)
do {
if ((error = action.execute(action.state, action.delay))) {
break;
}
} while (++index < count && (action = actions.shift()));
- In the execution of A, A schedules
AsapAction
B (as mentioned). The action queue is now[B]
. AsasapScheduler._scheduled
was set toundefined
on enteringAsapScheduler#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
- On returning from
action.execute
on A, the while expression will be evaluated. Bear in mind that we mutatedactions
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. - The next turn of the loop will call
action.execute()
on B. - 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.
- However, assuming no
AsapActions
were scheduled after B, thisflush
task is now redundant as B has already been executed and popped from the actions queue. - The async task enters flush, and because actions is
[]
,action = action || actions.shift()!;
isundefined
. - 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.
Activity