Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d2f5936
feat: record DDP subscription readiness on the entry, scoped to a con…
diegolmello Aug 18, 2026
d7daa28
refactor: drop the added comments and match confirmed subs through fi…
diegolmello Aug 18, 2026
7d498b3
chore: untrack scratch files committed by mistake
diegolmello Aug 18, 2026
797aebe
refactor: keep readiness in a Socket-owned set instead of a generatio…
diegolmello Aug 18, 2026
3b4307e
chore: untrack scratch files committed by mistake
diegolmello Aug 18, 2026
bc8ff23
fix: turn every sub Unconfirmed the moment the connection closes
diegolmello Aug 19, 2026
017e810
refactor: name the confirmed-id set for what it holds
diegolmello Aug 19, 2026
c007c47
refactor: spell out subscription in the driver spec helpers
diegolmello Aug 19, 2026
bed66f8
refactor: name the readiness listener for what it decides
diegolmello Aug 19, 2026
80cb268
refactor: ask the socket whether a stream has a confirmed sub
diegolmello Aug 19, 2026
7a86269
test: really record two entries for one stream in the readiness spec
diegolmello Aug 19, 2026
1114d40
Merge remote-tracking branch 'origin/mobile' into diegolmello/design-…
diegolmello Aug 19, 2026
2e9687c
refactor: match confirmed subs exactly in one predicate
diegolmello Aug 19, 2026
8b47a8e
refactor: use the glossary terms in ADR-0011 and drop the duplicated …
diegolmello Aug 19, 2026
fff812c
refactor: call a nosub carrying a DDP error a failed one in ADR-0011
diegolmello Aug 19, 2026
596727e
refactor: match confirmed subs through findSubscriptions
diegolmello Aug 19, 2026
16ba7cb
test: name the resolution tracker once in a shared helper
diegolmello Aug 19, 2026
299c30d
test: pin that asking whenReady for nothing resolves true
diegolmello Aug 19, 2026
8db1fe5
refactor: record confirmation on the subscription entry
diegolmello Aug 19, 2026
847ad9e
test: read the newest fake socket through the shared helper
diegolmello Aug 19, 2026
1656e98
docs: describe readiness as a field on the entry in ADR-0011
diegolmello Aug 19, 2026
72f8ef8
refactor: declare confirmed on ISubscription
diegolmello Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ _Avoid_: Sub, subscription (unqualified), the map, the collection (that is a fie
A DDP subscription whose `sub` reached the wire but whose DDP response the connection ended before delivering. The server may have acted on it, so its entry is kept and re-established rather than forgotten.
_Avoid_: Lost subscription, orphaned stream, phantom

**Confirmed sub**:
A DDP subscription whose `ready` DDP response arrived on the current connection. Confirmation belongs to the connection it arrived on: a Reopen makes every sub Unconfirmed, however the previous connection ended.
_Avoid_: Active subscription, live sub

**Unconfirmed sub**:
A recorded DDP subscription that is not a Confirmed sub — its `ready` never arrived, or arrived on an earlier connection. The record is an instruction to establish the stream, not a claim the server holds it.
_Avoid_: Pending subscription, stale sub

**Readiness**:
Whether every stream a caller named is a Confirmed sub right now. The Socket records it and answers from the record — asking costs no wire traffic and sends no `sub`. Scoped to a connection, like the confirmations it reads.
_Avoid_: Ready state (that is the Transport's), live, healthy

**Method call**:
A named server procedure invoked over the realtime connection, as opposed to a REST request.
_Avoid_: RPC, command
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The server's answer decides; silence keeps the instruction.
under the id the request was sent with. A `sub` the server refused with a
`nosub` carrying a DDP error still leaves nothing behind.
- Either write is conditional on the Socket still holding a connection.
`rememberSubscription` returns early when it holds none, because an entry is an
`recordSubscription` returns early when it holds none, because an entry is an
instruction to a later Login on this Socket, and a Socket with no connection has
nothing to instruct.
- A `sub` that was never written to the Transport leaves nothing behind. A failed
Expand Down Expand Up @@ -103,12 +103,12 @@ The server's answer decides; silence keeps the instruction.
- `unsubscribeAll` acts on these entries and sends `unsub` frames for them, on
the terms ADR-0004 sets. `Socket.close()` forgets them all and sends nothing,
under ADR-0009.
- `Socket.resubscribeWhenRecorded`, behind `Driver.waitForNotifyUserMediaSubs`,
polls `subscriptions` for the two media entries, and an entry written on an
abandoned `sub` ends that poll instead of keeping it waiting. Readiness itself
is unchanged: the poll only decides when to re-send, and the gate resolves on
whether that resubscribe was acknowledged. An abandoned one resolves
`undefined`, which the gate counts as unacknowledged.
- `Driver.waitForNotifyUserMediaSubs` reads the two media entries, and an entry
written on an abandoned `sub` counts as one of them. Readiness itself is
unchanged by this ADR: an entry is an instruction to establish the stream, not
a confirmation. ADR-0011 replaces the mechanism behind that gate with
`Socket.whenReady`, which answers from the recorded confirmations instead of
from the presence of an entry.
- `unsubscribe` already keeps its entry on the same class of rejection, so a
forced reconnect that abandons an `unsub` and a `sub` together leaves both, and
`subscribeAll` re-sends the `sub` at the next Login under an id whose `unsub`
Expand Down
94 changes: 94 additions & 0 deletions docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# ADR-0011: Subscription readiness is recorded by the Socket, scoped to a connection

**Status:** Accepted

**Succeeds:** ADR-0006

## Context

Issue 312: the Socket keeps no record of whether a DDP subscription was
confirmed. The caller that needs one is Rocket.Chat.ReactNative's call-accept
path: after a forced reconnect the app must know when its `media-signal` and
`media-calls` streams are Confirmed subs again before it answers. What it has today is
`resubscribeWhenRecorded`, which polls `subscriptions` every 100ms until the
entries exist, re-sends each under the id it was first sent with, and resolves
on whether the re-send was confirmed.

Three properties of that mechanism fail the caller.

Readiness is nowhere recorded. A caller that arrives after the streams
confirmed cannot learn it; the only way to ask is to send another `sub`.

The re-send is the readiness check. Sending a `sub` to learn whether a `sub`
is needed only works while the session is authenticated. A reopened connection
is anonymous until the app logs in again, and that window is reachable: the app
forces a reconnect on foreground and inside the call-accept path itself, and
re-logs in only when a `close` flipped its stored connection state. On the
anonymous session the server refuses the `sub` with a failed `nosub`, and per
ADR-0004 that refusal forgets the entry — the check destroys the state it
checks. `loggedIn` cannot gate the re-send either: it is `connected &&
!!resume`, `resume` survives across connections, and nothing clears it, so an
anonymous reopened session reports logged in.

The poll exists because the only readiness signal on offer was the DDP response
to the mechanism's own re-send. Once readiness is recorded, the signal is the record,
and the poll has nothing left to wait for.

The re-send itself cannot be repaired inside this issue. The only way to know a
session is authenticated is to log it in, and resume login on a reopened
connection is its own change — it was switched off deliberately once already
and the reason has to be re-established first. It is tracked as #359.

## Decision

Readiness is recorded state; the query is derived; the query sends nothing.

- Readiness lives on the entry, as a `confirmed` field the Socket writes when
the `sub` response names the id. One fact in one place: forgetting the entry
forgets the confirmation with it. `whenReady` is a thin query over the
entries: it resolves `true` when every stream asked for has a Confirmed sub,
`false` when the Deadline rings first, and never rejects. It sends no `sub`
of its own.
- Readiness is scoped to a connection: `onClose` unconfirms every entry the
moment the connection ends and `createConnection` unconfirms again for the
new one, so a reopen turns every sub Unconfirmed.
- A waiter learns of a confirmation through the Socket's own emitter, which
ADR-0002 hardens, rather than a second notification mechanism beside it.
- The 100ms poll dies with the mechanism that needed it. The re-send stays
where it has always belonged: `subscribeAll` on Login. A reopen sends
nothing, so on an anonymous reopened session the entries survive
Unconfirmed and `whenReady` resolves `false` at the Deadline — pinned as a
test. The re-send on that path returns when #359 makes the reopened session
authenticated.
- `ISocket.resubscribeWhenRecorded` is replaced by
`whenReady(streams, timeoutMs?): Promise<boolean>`, the Deadline defaulting
to `config.timeout`. `IDriver.waitForNotifyUserMediaSubs` keeps its
signature and becomes a caller of `whenReady`.
- A stream matches exactly: same name, same params length, element-wise `===`.
`findSubscriptions` keeps the prefix match its current callers rely on, so
the readiness path reads it and then requires the params length to agree.
- When no entry exists yet, `whenReady` waits until the Deadline rather than
answering early — the `sub` may still be in flight — and resolves `false`.

## Consequences

- `waitForNotifyUserMediaSubs` no longer re-sends. On the call-accept path
after a forced reconnect without a login, it resolves `false` at the
Deadline where the old mechanism re-subscribed. That is a visible behaviour
change on a path where today's code loses the entries anyway — the refused
re-send deletes them — so what is traded away is a re-send that failed
destructively, and what is gained is an honest answer and entries that
survive to be re-established at the next Login. #359 closes the gap by
making the reopened session authenticated.
- Any stream's readiness can be asked, not only the two media streams, and the
answer costs no wire traffic once it is recorded.
- An entry confirmed on a previous connection reads Unconfirmed the moment a
new connection is created, however the old one ended.
- The pinning suite keeps its assertions on `id`, `name` and `params`. Tests
that inferred readiness from a re-send going out are rewritten against the
recorded state, and the Driver reopen test gains the Login the real app
performs, plus a sibling pinning that a reopen without a login resolves
`false` at the Deadline.
- The two `nosub` shapes this work surfaced — a failed one forgets the
entry, one without a DDP error keeps it, and the SDK never inspects `msg`
to tell them apart — are unchanged here and tracked as #360.
1 change: 1 addition & 0 deletions interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export interface ICallback {
export interface ISubscription {
id?: string
name?: any
confirmed?: boolean
unsubscribe: () => Promise<any>
onEvent?: (callback: ISocketMessageCallback) => void
[key: string]: any
Expand Down
2 changes: 1 addition & 1 deletion lib/clients/Rocketchat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default class RocketChatClient extends ApiRocketChat {
async subscribeRaw (...args: any[]): Promise<ISubscription | undefined> { return this.driver.subscribeRaw(...args) }
async unsubscribe (subscription: ISubscription): Promise<any> { return this.driver.unsubscribe(subscription) }
async unsubscribeAll (): Promise<any> { return this.driver.unsubscribeAll() }
async resubscribeWhenRecorded (streams: IStream[], timeoutMs?: number): Promise<boolean> { return this.driver.resubscribeWhenRecorded(streams, timeoutMs) }
async whenReady (streams: IStream[], timeoutMs?: number): Promise<boolean> { return this.driver.whenReady(streams, timeoutMs) }
async subscribeRoom (rid: string, ...args: any[]): Promise<(ISubscription | undefined)[]> { return this.driver.subscribeRoom(rid, ...args) }
async subscribeNotifyAll (): Promise<any> { return this.driver.subscribeNotifyAll() }
async subscribeLoggedNotify (): Promise<any> { return this.driver.subscribeLoggedNotify() }
Expand Down
Loading
Loading