Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: race condition in client.listen memory leak #805

Merged
merged 1 commit into from
May 13, 2024

Conversation

stipsan
Copy link
Member

@stipsan stipsan commented May 13, 2024

While implementing client.live.events() the code underlying client.listen was used and a new race condition were discovered that leads to instances of new EventSource that should've been closed, but are instead left open.
In client.live.events() the race condition were pretty easy to reproduce, all you need is a React application in StrictMode and a useEffect implementation:

import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'pv8y60vp',
  dataset: 'production',
  useCdn: true,
  apiVersion: 'X',
})

export default function Example() {
  useEffect(() => {
    const subscription = client.live.events().subscribe({
      next: (event) => {
        console.log({event})
      },
      error: (err) => console.error(err),
    })
    return () => {
      subscription.unsubscribe()
    }
  }, [])

  return null
}

Inspecting the browser devtools networks tab would reveal two instances of an EventSource, where it should've been 1.

Since client.live.events() is built upon client.listen() I assumed the same reproduction would work there as well, but it doesn't:

import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'pv8y60vp',
  dataset: 'production',
  useCdn: true,
  apiVersion: 'X',
})

export default function Example() {
  useEffect(() => {
    const query = '*[_type == "$type"]'
    const params = {type: 'post'}
    const subscription = client.listen({query, params}).subscribe({
      next: (event) => {
        console.log({event})
      },
      error: (err) => console.error(err),
    })
    return () => {
      subscription.unsubscribe()
    }
  }, [])

  return null
}

It turns out the reason it was so easily, and consistently, reproduced in client.live.events(), but not for client.listen(), turned out to be that client.listen always lazy imports @sanity/eventsource, while client.live.events() only does so if there's no native globalThis.EventSource available. Since dynamic ESM imports are resolved at the same time, no matter what call site or call order, it's possible to consistently reproduce the race condition by waiting with calling .unsubscribe() until after the dynamic import is resolved:

     return () => {
-      subscription.unsubscribe()
+      import('@sanity/eventsource').then(() => subscription.unsubscribe())
     }

I tried to capture this in a unit test but it was too flaky and I eventually gave up.
Instead I have a Codesandbox instance that demonstrates it:
image

And here's the same sandbox but demonstrating that the fix works.

@stipsan stipsan enabled auto-merge (squash) May 13, 2024 14:16
@stipsan stipsan requested a review from rexxars May 13, 2024 14:22
Copy link
Member

@rexxars rexxars left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great find. Had to double and triple read the flow here to understand, so for posterity:

  1. getEventSource() does its async module load, checks if unsubscribed - which at this point it is not.
  2. Creates an ES with listeners, synchronously. Returns.
  3. The event loop yields, because it's an async fn. This is the race condition that was unaccounted for.
  4. open() receives the EventSource, but incorrectly assumed that we could not have unsubscribed between the module load and now.

@stipsan stipsan merged commit d2e468a into main May 13, 2024
16 checks passed
@stipsan stipsan deleted the fix-listen-race-condition branch May 13, 2024 14:37
@ecospark ecospark bot mentioned this pull request May 13, 2024
@github-actions github-actions bot locked as resolved and limited conversation to collaborators May 21, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants