RocketChat / RocketChat/Rocket.Chat.js.SDK
unsubscribe leaves the stream's listeners attached (refiling #326)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 146
- Forks
- 99
- Avg merge
- 7h 59m
- Merged PRs (30d)
- 17
Description
Refiling #326, which was closed while subscription work was being tracked outside GitHub rather than because it was fixed. Still present at eef075c8fce25120fda2ef77d69dba842af5d1ba, now with a measurement.
What to build
Ending a DDP subscription releases the listeners it registered, so nothing leaks per subscribe/unsubscribe cycle and a stopped stream stops looking live to whoever still holds it.
unsubscribe sends unsub and calls forgetSubscription, which only deletes the registry entry. The callbacks a caller passed to subscribe are registered on the socket's emitter bound to the stream name, not to the subscription id. Nothing ever removes them.
So one listener leaks per subscribe that passed a callback, and the leak is permanent for the session. It also means a stopped stream still looks live from the caller's side: the callback is still attached, so nothing observable distinguishes "subscribed" from "the server stopped sending".
Measured
Over the fake transport, two subscribe calls at one (name, params) and then one unsubscribe:
- One
subframe, one derived id, one registry entry; the second caller receives the identical record object. - Both callbacks fire while both are attached.
- After one
unsubscribe: theunsubframe goes out under the shared id, the entry is deleted, and a furtherunsubscribe()from either holder rejects with[ddp] No subscription to unsubscribe from. - Both callbacks are still attached — delivering another
changedframe straight from the transport fires both mocks, the leaver's included. - A later
subscribeAllre-sends nothing, since it iterates the now-empty registry.
Scope
Not user-facing in the mobile app, which never passes a callback to subscribe — it uses onStreamData, which does return a stop. #326 made the same scope note. This is a correctness and hygiene problem, not a live defect for the consumer.
Relationship to ADR-0011
ADR-0011 owns the sharing and the absent holder count deliberately, and its discussion of name-keying explains it as what makes delivery happen once. It never says what teardown should do with those listeners. That gap is what this issue is about — the missing refcount is the decided contract; the orphaned listeners are an undocumented edge of it.
Fix order
From #326, still correct — the two steps must land in this order:
- Store the callback on the subscription entry, so
subscribeAllcan hand it back when re-establishing on reopen. This carries an interface decision — whether the callback lives on the entry or on a returned handle — worth settling before the code moves. - Then remove the listener in
forgetSubscription.
Reversing the order silently kills delivery after every reopen, because the re-established subscription has no callback to hand back.
Acceptance criteria
- A subscription entry carries the callback its subscriber passed, and re-establishing the stream after a reopen hands that callback back, so delivery survives a reopen.
- Ending a subscription removes the listeners registered for it; a subscribe/unsubscribe cycle leaves no listener behind.
- A frame delivered after a subscription ended does not reach the callbacks of the holder that ended it.
- Delivery still happens exactly once per frame while a stream is shared by more than one holder.
- Specs cover the reopen path, not just the teardown, so the two steps can't regress independently.
Blocked by
None — can start immediately.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at the subscribe, unsubscribe, forgetSubscription, and subscribeAll entry points, then inspect the fake-transport subscription specs and the reopen path. Store the callback for re-establishment before removing listeners during teardown. Done means reopen delivery survives, ended subscriptions receive no later frames, and shared streams deliver each frame exactly once.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 64/100