fix(scheduler): AsyncScheduler.flush() kills sibling actions and tears down subscriber chains on error#7599
Open
stewartmcgown wants to merge 2 commits intoReactiveX:7.xfrom
Open
Conversation
…heduler flush PR ReactiveX#6674 fixed AnimationFrameScheduler and AsapScheduler to scope flush cycles using a flushId, so errors only kill actions belonging to the same flush. AsyncScheduler (and QueueScheduler which extends it) was never given the same fix. Its error handler blindly unsubscribed ALL remaining queued actions when any single action threw. This is the root cause of NgRx "store death": observeOn(queueScheduler) queues independent subscriber notifications in the same flush cycle. When one subscriber's handler throws, the error path destroyed every other subscriber's pending action — permanently killing the store. The erroring action already unsubscribes itself inside AsyncAction._execute(). The remaining actions are independent operations that should survive. Remove the blanket unsubscribe loop in the error path. Relates to ReactiveX#6672, ReactiveX#4690, ReactiveX#2697 Made-with: Cursor
…owing synchronously When AsyncScheduler.flush() throws synchronously, the error propagates back through the synchronous call stack — through QueueAction.schedule(), executeSchedule(), and into the OperatorSubscriber that initiated the flush. This tears down the entire subscriber chain, permanently killing pipelines like NgRx's observeOn(queueScheduler) → scan → State. Change flush() to use reportUnhandledError() instead of throw: - Errors surface asynchronously via config.onUnhandledError or setTimeout (consistent with how RxJS handles unhandled subscriber errors) - The synchronous subscriber chain is not torn down - Remaining queued actions continue executing in the same flush (they are independent operations unaffected by a sibling's error) The erroring action still unsubscribes itself immediately in _execute(). This is analogous to how ConsumerObserver.next already handles subscriber errors — catch and report asynchronously rather than propagating synchronously through the call stack. Made-with: Cursor
trxcllnt
approved these changes
Apr 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
AsyncScheduler.flush()has two bugs in its error handling that were never fixed, even though the same class of bugs were fixed inAsapSchedulerandAnimationFrameSchedulervia #6674 and #7444.Bug 1: Sibling actions are killed on error
When any action errors during a flush, the error handler unsubscribes ALL remaining queued actions:
The erroring action already unsubscribes itself in
AsyncAction._execute(). The remaining actions are independent operations that should not be affected.Bug 2: Synchronous throw tears down subscriber chains
After killing siblings,
flush()throws synchronously. ForQueueScheduler(delay=0), this throw propagates back through the synchronous call stack:QueueAction.schedule()→executeSchedule()→OperatorSubscriber._nextcatch →destination.error()— tearing down the entire subscriber pipeline.This is the root cause of NgRx "store death":
observeOn(queueScheduler)queues independent subscriber notifications in the same flush cycle. When one errors, the throw propagates through the subscriber chain and unsubscribes the entireobserveOn → withLatestFrom → scan → Statepipeline.The Fix
while (actions.shift()) { action.unsubscribe() }loopthrow errorwithreportUnhandledError(error)and continue flushingdo { if ((error = action.execute(action.state, action.delay))) { - break; + reportUnhandledError(error); + error = null; } } while ((action = actions.shift()!)); this._active = false; - -if (error) { - while ((action = actions.shift()!)) { - action.unsubscribe(); - } - throw error; -}Errors surface asynchronously via
config.onUnhandledErrororsetTimeout— consistent with howConsumerObserver.nextalready handles subscriber errors in RxJS. The erroring action still self-cleans via_execute(). Remaining actions execute in the same flush.Background
AsapSchedulerandAnimationFrameSchedulerusingflushIdscoping — butAsyncSchedulerwas never given the same treatmentAnimationFrameSchedulerfixQueueSchedulerextendsAsyncSchedulerwithout overridingflush(), so it inherits the unfixed error handlingTest plan
config.onUnhandledError, not thrown synchronouslyobserveOn(queueScheduler)with two subscribers — one throws, both subscriptions survive, subsequent emissions still workcc @benlesh @cartant @pmoleri @trxcllnt
Made with Cursor