ReactiveX / ReactiveX/rxjs

Synchronous execution of disposer

Open
#7,230 6 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
31.7k
Forks
3k
PR merge metrics
No merged PRs in 30d

Description

Describe the bug

We implement an operator called combineMap which is a more efficient variant of the following pattern:

values$.pipe(rxjs.switchMap(values => value.length > 0 ? rxjs.combineMap(values.map(selector)) : rxjs.of([]) 

Instead, we can do the following which will re-use the results and subscriptions for unchanged values:

values$.pipe(combineMap(selector))

This works fine most of the time, but sometimes in production we get the following error:

    TypeError: Cannot read properties of null (reading 'unsubscribe')
        at /home/jesper/nxtedition/nxt/asset/node_modules/@nxtedition/lib/rxjs/combineMap.js:136:28
        at execFinalizer (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/Subscription.js:172:9)
        at Subscription.unsubscribe (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/Subscription.js:89:29)
        at Subscriber.unsubscribe (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/Subscriber.js:75:42)
        at OperatorSubscriber.unsubscribe (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/operators/OperatorSubscriber.js:72:42)
        at execFinalizer (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/Subscription.js:175:19)
        at Subscription.unsubscribe (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/Subscription.js:89:29)
        at Subscriber.unsubscribe (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/Subscriber.js:75:42)
        at OperatorSubscriber.unsubscribe (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/operators/OperatorSubscriber.js:72:42)
        at execFinalizer (/home/jesper/nxtedition/nxt/asset/node_modules/rxjs/dist/cjs/internal/Subscription.js:175:19)

Give the following code:

const EMPTY = Object.freeze([])

function combineMap(project, equals = (a, b) => a === b) {
  const self = this
  return new rxjs.Observable((o) => {
    let curr = EMPTY
    let scheduled = false
    let dirty = false
    let active = 0
    let empty = 0

    const _error = (err) => o.error(err)

    function _update() {
      scheduled = false

      if (empty) {
        return
      }

      if (dirty) {
        dirty = false
        o.next(curr.map((context) => context.value))
      }

      if (!active) {
        o.complete()
      }
    }

    function update() {
      if (!scheduled) {
        scheduled = true
        queueMicrotask(_update)
      }
    }

    active += 1
    const subscription = self.subscribe({
      next(keys) {
        if (!Array.isArray(keys)) {
          keys = EMPTY
        }
        // TODO (perf): Avoid array allocation & copy if nothing has updated.

        const prev = curr
        curr = new Array(keys.length)

        const prevLen = prev.length
        const currLen = curr.length

        if (currLen !== prevLen || prev === EMPTY) {
          dirty = true
          update()
        }

        for (let n = 0; n < currLen; ++n) {
          const key = keys[n]

          if (n < prevLen && prev[n] && equals(prev[n].key, key)) {
            curr[n] = prev[n]
            prev[n] = null
            continue
          }

          dirty = true
          update()

          // TODO (perf): Guess start index based on n, e.g. n - 1 and n + 1 to check if
          // a key has simply been added or removed.
          const i = prev.findIndex((entry) => entry && equals(entry.key, key))

          if (i !== -1) {
            curr[n] = prev[i]
            prev[i] = null
          } else {
            const entry = (curr[n] = {
              key,
              value: EMPTY,
              subscription: null,
            })

            let observable
            try {
              observable = rxjs.from(project(keys[n]))
            } catch (err) {
              observable = rxjs.throwError(() => err)
            }

            empty += 1
            active += 1

            const subscription = observable.subscribe({
              next(value) {
                if (entry.value === EMPTY) {
                  empty -= 1
                }

                entry.value = value

                dirty = true
                update()
              },
              error: _error,
            })
            // ** outer disposer runs before this assignment is done
            entry.subscription = subscription
            entry.subscription.add(() => {
              if (entry.value === EMPTY) {
                empty -= 1
              }

              active -= 1

              dirty = true
              update()
            })
          }
        }

        // TODO (perf): start from index where prev[n] is not null.
        for (let n = 0; n < prevLen; n++) {
          // ** this will crash since subscription is null
          prev[n]?.subscription.unsubscribe()
        }
      },
      error: _error,
      complete() {
        active -= 1
        if (!active) {
          update()
        }
      },
    })

    return () => {
      for (const entry of curr) {
        entry?.subscription.unsubscribe()
      }
      subscription.unsubscribe()
    }
  })
}

For some reason the disposer:

    return () => {
      for (const entry of curr) {
        entry?.subscription.unsubscribe()
      }
      subscription.unsubscribe()
    }

Runs before subscription has been assigned above:

entry.subscription = subscription

I have no idea how this can occur and have been unable to reproduce it.

Expected behavior

The outer disposer does not run concurrently with the inner subscription's next invocation.

Reproduction code

Unable to reproduce outside of production code.

Reproduction URL

No response

Version

7.8.0

Environment

No response

Additional context

No response

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start from the combineMap implementation shown in the issue and trace RxJS Subscription.unsubscribe and execFinalizer, especially the ordering around the inner observable's subscribe callback and entry.subscription assignment. Add a regression test that exercises synchronous disposal during subscription, then verify the outer disposer cannot access an unassigned inner subscription while preserving cleanup of active entries.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.