fix(core): keep the cached text stream observable across a room disconnect - #1425
fix(core): keep the cached text stream observable across a room disconnect#1425daniel1014 wants to merge 2 commits into
Conversation
|
@daniel1014 is attempting to deploy a commit to the LiveKit Team on Vercel. A member of the Team first needs to authorize it. |
🦋 Changeset detectedLatest commit: d704333 The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Btw I just tried this link to sign the required CLA but the link appears broke blocking me to sign anything on it. It will be really appreciated to have this PR being reviewed and merged as it's a blocking issue around livekit cache (see the details above) requiring manual workaround patch. |
|
Thanks for the PR! However the fix as you suggest would keep the cache entries around indefinitely. I'd suggest instead to use a WeakMap keyed on the Room instances like const cache: WeakMap<Room, Map<string, Observable<TextStreamData[]>>>so that the entries can get dropped if the Room instance isn't used anymore. This also allows us to drop the RoomEvent.Disconnected handler entirely (buffer reset can move into Let me know if you want to tackle that yourself or if I should create a PR with the fix. |
This is unexpected, do you get an error message that I can use to investigate what might be wrong? |
Address review: instead of keeping a global Map keyed by a generated room
id, hold a WeakMap<Room, Map<topic, Observable>> so entries are collected
with the room. The RoomEvent.Disconnected listener is gone entirely - the
buffer reset moves into tap({ subscribe }), which share() runs once per
subscription window.
c7275b6 to
d704333
Compare
|
Thanks! I went with the WeakMap keyed on Re the CLA: it shows as signed now — the badge was stale at the time. Sorry for the noise on that. |
Summary
setupTextStreamcaches one observable perroom:topicin a globalMap, registers the handler intap({ subscribe })and unregisters it infinalize(refcounted viashare()). But theRoomEvent.Disconnectedlistener also deletes the cache entry:Deleting the entry does not invalidate consumers that already hold the observable. So on a reused
Roominstance:useTextStreampassesundefinedwhen disconnected), the handler is unregistered, and the cache entry is dropped — but A still holds observable wip #1.topic(useMemo(..., [room, topic])rebuilds without a remount), while disconnected → cache miss → observable Restructure disconnect #2 for the same topic.tap({ subscribe })fire →registerTextStreamHandleris called twice → livekit-client throws:The second subscription dies permanently: that topic never delivers text again for the life of the page, while everything else on the room (tracks, other topics) keeps working — which makes it look like a UI bug rather than a stream-handler collision.
This is a leftover from #1188 ("don't unregister stream handler on disconnect"), whose stated goal is exactly this scenario — "This ensures that a room instance can be reused and the transcription handler stays registered even after a disconnect." That PR correctly introduced the refcount pattern, but kept the
getObservableCache().delete(cacheKey)line from the pre-refactor version (where the subject was also completed, so eviction was correct). With the subject now long-lived, the eviction is what breaks reuse.Fix
Per @lukasIO's review — key the cache on the
Roominstance itself instead of evicting on disconnect:The entries for a room are collected with the room, so nothing is retained indefinitely, and a reused room keeps its observables across any number of connect/disconnect cycles.
That makes the
RoomEvent.Disconnectedlistener unnecessary and it is removed entirely:WeakMap.tap({ subscribe }).share()resets on refcount zero, so that callback runs once per subscription window — the first subscriber, and again after every reconnect — which is exactly where a fresh buffer belongs. React consumers already reset their own state on disconnect:useTextStreamswaps the observable forundefined, anduseObservableStateresets tostartWithwhenever the observable identity changes, so dropping thenext([])emission is not observable to them.Removing the listener also fixes a leak introduced by #1188, which changed
room.once→room.onwithout a matchingoff: every cache-key rebuild added another permanentDisconnectedlistener.This also deletes the
roomInstanceMap/nextRoomIdstring-key machinery, which only existed to synthesise a key theWeakMapnow provides directly.Repro
Reproduced in an app that mounts
useTextStreamconsumers under a<LiveKitRoom connect={...}>toggle: connect → disconnect → change a consumer's topic (or mount a new one) → reconnect. The regression tests are the minimal version of that, using a fakeRoomthat mirrors livekit-client's one-handler-per-topic contract.Test plan
New
packages/core/src/components/textStream.test.ts:reuses one observable per room and topic across disconnect/reconnect— red onmain, and it fails with the production error rather than a bare identity mismatch:starts each subscription window with an empty buffer— also red onmain(expected [ 2 ] to deeply equal [ 1 ]); guards the buffer-reset behaviour that moved intotap({ subscribe }).caches per room instance, so a second room gets its own observable— guards the newWeakMapkeying.Baseline on
main(fake room unchanged):Tests 2 failed | 1 passed (3). With the fix:Tests 3 passed (3).Suites:
packages/core:pnpm test→Test Files 10 passed (10)/Tests 101 passed (101);tsc --noEmitexit 0;pnpm lintreports only the 14 pre-existing warnings, none in the touched files.packages/react:pnpm test→Test Files 4 passed (4)/Tests 14 passed (14).Changeset included (
patchon@livekit/components-core).