From d2f5936bec9fc992506711dbe11ee2d86bd9f3a5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 17:07:03 -0300 Subject: [PATCH 01/21] feat: record DDP subscription readiness on the entry, scoped to a connection whenReady(streams, timeoutMs?) replaces resubscribeWhenRecorded: a query over the recorded Confirmed subs that sends no sub of its own, matches exactly, never rejects, and resolves false at the deadline. The 100ms poll dies with it; the re-send stays in subscribeAll on Login. Closes #312 --- CONTEXT.md | 8 + ...tion-readiness-is-recorded-on-the-entry.md | 93 ++++++ interfaces/index.ts | 1 + lib/clients/Rocketchat.ts | 2 +- lib/drivers/__tests__/driver.spec.ts | 277 ++++++------------ .../__tests__/socket.subscriptions.spec.ts | 107 +++++-- lib/drivers/definitions.ts | 2 +- lib/drivers/driver.ts | 20 +- lib/drivers/socket.ts | 79 ++--- 9 files changed, 320 insertions(+), 269 deletions(-) create mode 100644 docs/adr/0009-subscription-readiness-is-recorded-on-the-entry.md diff --git a/CONTEXT.md b/CONTEXT.md index 2d82394..0a1e392 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -74,6 +74,14 @@ _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 + **Method call**: A named server procedure invoked over the realtime connection, as opposed to a REST request. _Avoid_: RPC, command diff --git a/docs/adr/0009-subscription-readiness-is-recorded-on-the-entry.md b/docs/adr/0009-subscription-readiness-is-recorded-on-the-entry.md new file mode 100644 index 0000000..8c3be33 --- /dev/null +++ b/docs/adr/0009-subscription-readiness-is-recorded-on-the-entry.md @@ -0,0 +1,93 @@ +# ADR-0009: Subscription readiness is recorded on the entry, 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 live 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 acknowledged. + +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 an errored `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 ack of 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 subscription entry, and the entry is the single + source of truth for 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 by a generation the Socket mints and + increments in `createConnection`. The entry stores the generation it was + confirmed on, and is a Confirmed sub only while that generation is the + current one. A reopen therefore turns every entry Unconfirmed without a pass + over `subscriptions` — the comparison does the forgetting. +- 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`, 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 `===`. + The prefix match in `findSubscriptions` stays as it is for its current + callers and is kept out of the readiness path. +- 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` — they + match with `toMatchObject`, so the generation field breaks nothing. 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 — an errored one forgets the + entry, an errorless one keeps it, and the SDK never inspects `msg` to tell + them apart — are unchanged here and tracked as #360. diff --git a/interfaces/index.ts b/interfaces/index.ts index f38766c..95714c7 100644 --- a/interfaces/index.ts +++ b/interfaces/index.ts @@ -184,6 +184,7 @@ export interface ISubscription { name?: any unsubscribe: () => Promise onEvent?: (callback: ISocketMessageCallback) => void + confirmedOnGeneration?: number [key: string]: any } diff --git a/lib/clients/Rocketchat.ts b/lib/clients/Rocketchat.ts index a934a53..2de2806 100644 --- a/lib/clients/Rocketchat.ts +++ b/lib/clients/Rocketchat.ts @@ -31,7 +31,7 @@ export default class RocketChatClient extends ClientRest implements ISocket { async subscribeRaw (...args: any[]): Promise { return this.ddp.subscribeRaw(...args) } async unsubscribe (subscription: ISubscription): Promise { return this.ddp.unsubscribe(subscription) } async unsubscribeAll (): Promise { return this.ddp.unsubscribeAll() } - async resubscribeWhenRecorded (streams: IStream[], timeoutMs?: number): Promise { return this.ddp.resubscribeWhenRecorded(streams, timeoutMs) } + async whenReady (streams: IStream[], timeoutMs?: number): Promise { return this.ddp.whenReady(streams, timeoutMs) } async subscribeRoom (rid: string, ...args: any[]): Promise<(ISubscription | undefined)[]> { return this.ddp.subscribeRoom(rid, ...args) } async subscribeNotifyAll (): Promise { return this.ddp.subscribeNotifyAll() } async subscribeLoggedNotify (): Promise { return this.ddp.subscribeLoggedNotify() } diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index c2bd3bd..6413554 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -6,6 +6,7 @@ import { driveToHandshake, FakeWebSocket, fakeSockets, + flushMicrotasks, openFakeConnection, useFakeClockAndSocketRegistry } from '../../../test/fakeTransport' @@ -101,8 +102,6 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { /** Register a subscription on the Socket under a given id, as a successful sub would. */ const addSub = async (driver: Driver, transport: FakeWebSocket, event: string, id: string) => { - // Through the Socket, with an explicit id: this is the shape the - // readiness poll looks for — `name` the topic, `params[0]` the user event. const subscribing = driver.ddp.subscribe(topic, [event], undefined, id) transport.receive({ msg: 'ready', subs: [id] }) await subscribing @@ -116,8 +115,6 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) const sentBefore = transport.sent.length - // The socket's own ping timer is pending here and stays that way; what the - // guard has to avoid is adding the poll and the deadline on top of it. const timersBefore = jest.getTimerCount() await expect(driver.waitForNotifyUserMediaSubs()).resolves.toBe(false) @@ -126,158 +123,58 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { expect(jest.getTimerCount()).toBe(timersBefore) }) - it('resolves ready after an immediate reopen, on the socket the reopen built', async () => { + it('resolves true when both media streams are confirmed on the current generation', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') - - // A real reopen: a second transport is constructed and handshaken, and the - // subscription map survives it — which is what makes the resubscribe below - // reuse the ids rather than mint new ones. - const reopening = driver.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - expect(reopened).not.toBe(transport) - await driveToHandshake(reopened, 'reopened-session') - await reopening - - const waiting = driver.waitForNotifyUserMediaSubs() - // The resubscribes go out on the new socket, not the dead one. - expect(reopened.sent.map((frame) => JSON.parse(frame).id)) - .toEqual(expect.arrayContaining(['sub-media-signal', 'sub-media-calls'])) - reopened.receive({ msg: 'ready', subs: ['sub-media-signal'] }) - reopened.receive({ msg: 'ready', subs: ['sub-media-calls'] }) - - await expect(waiting).resolves.toBe(true) - }) - - it('does not resubscribe again while a resubscribe is still in flight', async () => { - const driver = createDriver() - const transport = await openFakeConnection(driver.ddp) - driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') - - const sentBefore = transport.sent.length - const waiting = driver.waitForNotifyUserMediaSubs() - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }, () => { resolved = false }) - - // The server never acks, so the first attempt stays in flight across many - // poll ticks. Without the latch each tick would fire another pair of - // resubscribes — a storm the server would see as repeated sub requests. - await jest.advanceTimersByTimeAsync(1000) - - expect(transport.sent).toHaveLength(sentBefore + 2) - expect(resolved).toBeUndefined() - - // Left settled so the pending timers do not outlive the test. - transport.receive({ msg: 'ready', subs: ['sub-media-signal'] }) - transport.receive({ msg: 'ready', subs: ['sub-media-calls'] }) - await expect(waiting).resolves.toBe(true) - }) - - it('polls until both media subscriptions appear, then resubscribes on their own ids', async () => { - const driver = createDriver() - const transport = await openFakeConnection(driver.ddp) - // The field login writes. Assigning it keeps the reconnect being reproduced - // here — subscriptions not yet restored — reachable without a login round. - driver.userId = userId const waiting = driver.waitForNotifyUserMediaSubs() let resolved: boolean | undefined - waiting.then((value) => { resolved = value }, () => { resolved = false }) - - // Nothing to find yet, and one poll interval passing changes nothing. - await jest.advanceTimersByTimeAsync(100) - expect(resolved).toBeUndefined() + waiting.then((value: boolean) => { resolved = value }) - // Half of what it waits for is not enough: the poll wants both. - await addMediaSub(driver, transport, 'media-signal') - await jest.advanceTimersByTimeAsync(100) + await jest.advanceTimersByTimeAsync(1) expect(resolved).toBeUndefined() await addMediaSub(driver, transport, 'media-calls') - const sentBefore = transport.sent.length - await jest.advanceTimersByTimeAsync(100) - - // Both resubscribes go out on the existing ids, so the server treats them as - // the same subscriptions rather than minting new ones. - const resent = transport.sent.slice(sentBefore).map((frame) => JSON.parse(frame)) - expect(resent).toEqual([ - { msg: 'sub', id: 'sub-media-signal', name: topic, params: [`${userId}/media-signal`] }, - { msg: 'sub', id: 'sub-media-calls', name: topic, params: [`${userId}/media-calls`] } - ]) - - // Still pending until the server acks both: readiness is the ack, not the send. - expect(resolved).toBeUndefined() - transport.receive({ msg: 'ready', subs: ['sub-media-signal'] }) - transport.receive({ msg: 'ready', subs: ['sub-media-calls'] }) - await expect(waiting).resolves.toBe(true) }) - it('resolves false when the server refuses both resubscribes', async () => { + it('resolves false at the deadline when no entry exists', async () => { const driver = createDriver() - const transport = await openFakeConnection(driver.ddp) - driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') - - const waiting = driver.waitForNotifyUserMediaSubs() - transport.receive({ msg: 'nosub', id: 'sub-media-signal' }) - transport.receive({ msg: 'nosub', id: 'sub-media-calls' }) - - await expect(waiting).resolves.toBe(false) - }) - - it('resolves false when only one of the two resubscribes is refused', async () => { - const driver = createDriver() - const transport = await openFakeConnection(driver.ddp) + await openFakeConnection(driver.ddp) driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') - const waiting = driver.waitForNotifyUserMediaSubs() - transport.receive({ msg: 'ready', subs: ['sub-media-signal'] }) - transport.receive({ msg: 'nosub', id: 'sub-media-calls' }) - - await expect(waiting).resolves.toBe(false) - }) - - it('resolves false when a resubscribe is acked without a subscription id', async () => { - const driver = createDriver() - const transport = await openFakeConnection(driver.ddp) - driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') + const waiting = driver.waitForNotifyUserMediaSubs(500) + const timersBefore = jest.getTimerCount() - const waiting = driver.waitForNotifyUserMediaSubs() - // Answered, but carrying nothing to subscribe with: the stream is no more - // restored than it is by a refusal. - transport.receive({ msg: 'ready', id: 'sub-media-signal', subs: [] }) - transport.receive({ msg: 'ready', subs: ['sub-media-calls'] }) + await jest.advanceTimersByTimeAsync(499) + expect(jest.getTimerCount()).toBe(timersBefore) + await jest.advanceTimersByTimeAsync(1) await expect(waiting).resolves.toBe(false) }) - it('leaves the user\'s other streams on the same topic alone', async () => { + it('waits while a subscription is still in flight', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId - await addSub(driver, transport, `${userId}/message`, 'sub-message') - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') - const sentBefore = transport.sent.length const waiting = driver.waitForNotifyUserMediaSubs() + let resolved: boolean | undefined + waiting.then((value: boolean) => { resolved = value }) - const resent = transport.sent.slice(sentBefore).map((frame) => JSON.parse(frame).id) - expect(resent).toEqual(['sub-media-signal', 'sub-media-calls']) + driver.ddp.subscribe(topic, [`${userId}/media-signal`], undefined, 'sub-media-signal') + await jest.advanceTimersByTimeAsync(1) + expect(resolved).toBeUndefined() transport.receive({ msg: 'ready', subs: ['sub-media-signal'] }) + await jest.advanceTimersByTimeAsync(1) + + driver.ddp.subscribe(topic, [`${userId}/media-calls`], undefined, 'sub-media-calls') + await jest.advanceTimersByTimeAsync(1) transport.receive({ msg: 'ready', subs: ['sub-media-calls'] }) + await expect(waiting).resolves.toBe(true) }) @@ -288,65 +185,86 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await addSub(driver, transport, 'other-user/media-signal', 'sub-other-signal') await addSub(driver, transport, 'other-user/media-calls', 'sub-other-calls') - const sentBefore = transport.sent.length const waiting = driver.waitForNotifyUserMediaSubs(500) await jest.advanceTimersByTimeAsync(500) await expect(waiting).resolves.toBe(false) - expect(transport.sent).toHaveLength(sentBefore) }) - it('resubscribes every entry recorded for a media stream, and needs each acked', async () => { + it('leaves the user\'s other streams on the same topic alone', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId + await addSub(driver, transport, `${userId}/message`, 'sub-message') await addMediaSub(driver, transport, 'media-signal') - // A second entry for the same stream — what an abandoned sub re-sent under a - // fresh id leaves behind. Every entry is re-sent, and one refusal is enough. - await addSub(driver, transport, `${userId}/media-signal`, 'sub-media-signal-again') await addMediaSub(driver, transport, 'media-calls') - const sentBefore = transport.sent.length const waiting = driver.waitForNotifyUserMediaSubs() - - const resent = transport.sent.slice(sentBefore).map((frame) => JSON.parse(frame).id) - expect(resent).toEqual(['sub-media-signal', 'sub-media-signal-again', 'sub-media-calls']) - - transport.receive({ msg: 'ready', subs: ['sub-media-signal'] }) - transport.receive({ msg: 'nosub', id: 'sub-media-signal-again' }) - transport.receive({ msg: 'ready', subs: ['sub-media-calls'] }) - - await expect(waiting).resolves.toBe(false) + await expect(waiting).resolves.toBe(true) }) - it('resolves ready when the streams only land on the socket a reopen is still building', async () => { - const driver = createDriver() - await openFakeConnection(driver.ddp) - driver.userId = userId - - const reopening = driver.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - - // The gate opens before the new connection is even handshaken: nothing to - // find yet, so it is the poll that has to carry it across the reopen. - const waiting = driver.waitForNotifyUserMediaSubs() - - await driveToHandshake(reopened, 'reopened-session') - await reopening - - await addMediaSub(driver, reopened, 'media-signal') - await addMediaSub(driver, reopened, 'media-calls') - - const sentBefore = reopened.sent.length - await jest.advanceTimersByTimeAsync(100) - - const resent = reopened.sent.slice(sentBefore).map((frame) => JSON.parse(frame).id) - expect(resent).toEqual(['sub-media-signal', 'sub-media-calls']) + describe('after a reopen', () => { + it('resolves false at the deadline when no login re-confirms the entries', async () => { + const driver = createDriver() + const transport = await openFakeConnection(driver.ddp) + driver.userId = userId + await addMediaSub(driver, transport, 'media-signal') + await addMediaSub(driver, transport, 'media-calls') + + const reopening = driver.reopenNow() + const reopened = fakeSockets[fakeSockets.length - 1] + expect(reopened).not.toBe(transport) + await driveToHandshake(reopened, 'reopened-session') + await reopening + + const sentBefore = reopened.sent.length + const waiting = driver.waitForNotifyUserMediaSubs(500) + await jest.advanceTimersByTimeAsync(500) + + expect(reopened.sent).toHaveLength(sentBefore) + await expect(waiting).resolves.toBe(false) + }) - reopened.receive({ msg: 'ready', subs: ['sub-media-signal'] }) - reopened.receive({ msg: 'ready', subs: ['sub-media-calls'] }) - await expect(waiting).resolves.toBe(true) + it('resolves true after login re-sends and the server confirms on the new generation', async () => { + const driver = createDriver() + const transport = await openFakeConnection(driver.ddp) + driver.userId = userId + await addMediaSub(driver, transport, 'media-signal') + await addMediaSub(driver, transport, 'media-calls') + + const reopening = driver.reopenNow() + const reopened = fakeSockets[fakeSockets.length - 1] + expect(reopened).not.toBe(transport) + await driveToHandshake(reopened, 'reopened-session') + await reopening + + const waiting = driver.waitForNotifyUserMediaSubs() + const framesBefore = reopened.sent.length + const loggingIn = driver.ddp.login({ token: 'resume-token' } as any) + await flushMicrotasks() + + const loginFrame = reopened.sent[framesBefore] + expect(JSON.parse(loginFrame)).toMatchObject({ msg: 'method', method: 'login' }) + + reopened.receive({ + msg: 'result', + id: JSON.parse(loginFrame).id, + result: { id: userId, token: 'resume-token' } + }) + await loggingIn + await flushMicrotasks() + + const resent = reopened.sent.slice(framesBefore + 1).map((frame) => JSON.parse(frame)) + expect(resent).toEqual([ + { msg: 'sub', id: 'sub-media-signal', name: topic, params: [`${userId}/media-signal`] }, + { msg: 'sub', id: 'sub-media-calls', name: topic, params: [`${userId}/media-calls`] } + ]) + + reopened.receive({ msg: 'ready', subs: ['sub-media-signal'] }) + reopened.receive({ msg: 'ready', subs: ['sub-media-calls'] }) + await expect(waiting).resolves.toBe(true) + }) }) it('takes its deadline from the configured timeout when given none', async () => { @@ -357,7 +275,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const waiting = driver.waitForNotifyUserMediaSubs() let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + waiting.then((value: boolean) => { resolved = value }) await jest.advanceTimersByTimeAsync(timeout - 1) expect(resolved).toBeUndefined() @@ -366,22 +284,6 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await expect(waiting).resolves.toBe(false) }) - it('resolves false when the subscriptions never appear before the deadline', async () => { - const driver = createDriver() - await openFakeConnection(driver.ddp) - driver.userId = userId - - const waiting = driver.waitForNotifyUserMediaSubs(500) - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }, () => { resolved = false }) - - await jest.advanceTimersByTimeAsync(499) - expect(resolved).toBeUndefined() - - await jest.advanceTimersByTimeAsync(1) - await expect(waiting).resolves.toBe(false) - }) - it('leaves no timer behind once it settles', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) @@ -389,18 +291,11 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await addMediaSub(driver, transport, 'media-signal') await addMediaSub(driver, transport, 'media-calls') - // The socket's own ping timer is already pending and stays that way; only - // what the wait itself scheduled has to be gone by the end. const timersBefore = jest.getTimerCount() const waiting = driver.waitForNotifyUserMediaSubs() - // The first attempt runs synchronously, so both resubscribes are already out. - transport.receive({ msg: 'ready', subs: ['sub-media-signal'] }) - transport.receive({ msg: 'ready', subs: ['sub-media-calls'] }) await expect(waiting).resolves.toBe(true) - // Both the poll interval and the deadline are cleared: a leaked interval - // would keep resubscribing for the life of the process. expect(jest.getTimerCount()).toBe(timersBefore) }) }) diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index b8ec07a..0752600 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -150,56 +150,63 @@ describe('Socket subscription bookkeeping', () => { }) }) - describe('resubscribing once the streams asked for are present', () => { + describe('whenReady', () => { const streams = [ { name: 'stream-notify-user', params: ['uid/media-signal'] }, { name: 'stream-notify-user', params: ['uid/media-calls'] } ] - it('waits for every stream, then re-sends each under its own id', async () => { + it('resolves true once the streams are confirmed on the current generation', async () => { await subscribe('stream-notify-user', ['uid/media-signal']) - const waiting = socket.resubscribeWhenRecorded(streams) + const waiting = socket.whenReady(streams) let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + waiting.then((value: boolean) => { resolved = value }) - // One of the two is not enough to start. - await jest.advanceTimersByTimeAsync(100) + // One of the two is not enough. + await jest.advanceTimersByTimeAsync(1) expect(resolved).toBeUndefined() await subscribe('stream-notify-user', ['uid/media-calls']) - const sentBefore = transport.sent.length - await jest.advanceTimersByTimeAsync(100) + await expect(waiting).resolves.toBe(true) + }) - expect(transport.sent.slice(sentBefore).map((frame) => JSON.parse(frame))).toEqual([ - { msg: 'sub', id: 'ddp-1', name: 'stream-notify-user', params: ['uid/media-signal'] }, - { msg: 'sub', id: 'ddp-2', name: 'stream-notify-user', params: ['uid/media-calls'] } - ]) + it('waits when no entry exists yet', async () => { + const waiting = socket.whenReady(streams) + let resolved: boolean | undefined + waiting.then((value: boolean) => { resolved = value }) - // Readiness is the server's ack, not the send. + socket.subscribe('stream-notify-user', ['uid/media-signal']) + await jest.advanceTimersByTimeAsync(1) expect(resolved).toBeUndefined() - transport.receive({ msg: 'ready', subs: ['ddp-1'] }) - transport.receive({ msg: 'ready', subs: ['ddp-2'] }) + + const { id } = transport.lastSent() + transport.receive({ msg: 'ready', subs: [id] }) + await jest.advanceTimersByTimeAsync(1) + + socket.subscribe('stream-notify-user', ['uid/media-calls']) + await jest.advanceTimersByTimeAsync(1) + const { id: id2 } = transport.lastSent() + transport.receive({ msg: 'ready', subs: [id2] }) await expect(waiting).resolves.toBe(true) }) - it('resolves false on its deadline, and stops polling', async () => { - const waiting = socket.resubscribeWhenRecorded(streams, 500) + it('resolves false at the deadline when no entry exists', async () => { + const waiting = socket.whenReady(streams, 500) const timersBefore = jest.getTimerCount() - await jest.advanceTimersByTimeAsync(500) + await jest.advanceTimersByTimeAsync(499) + expect(jest.getTimerCount()).toBe(timersBefore) + await jest.advanceTimersByTimeAsync(1) await expect(waiting).resolves.toBe(false) - // Both the deadline and the poll interval are gone — a leaked interval - // would keep resubscribing for the life of the process. - expect(jest.getTimerCount()).toBe(timersBefore - 2) }) it('takes its deadline from the configured timeout when given none', async () => { - const waiting = socket.resubscribeWhenRecorded(streams) + const waiting = socket.whenReady(streams) let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + waiting.then((value: boolean) => { resolved = value }) await jest.advanceTimersByTimeAsync(socket.config.timeout - 1) expect(resolved).toBeUndefined() @@ -208,16 +215,58 @@ describe('Socket subscription bookkeeping', () => { await expect(waiting).resolves.toBe(false) }) - it('resolves false when the server refuses one of the resubscribes', async () => { - await subscribe('stream-notify-user', ['uid/media-signal']) - await subscribe('stream-notify-user', ['uid/media-calls']) + it('uses exact match: a same-name prefix params entry does not count', async () => { + // `findSubscriptions` matches on prefix; readiness must not. + await subscribe('stream-notify-user', ['uid/media-signal', false]) - const waiting = socket.resubscribeWhenRecorded(streams) - transport.receive({ msg: 'ready', subs: ['ddp-1'] }) - transport.receive({ msg: 'nosub', id: 'ddp-2' }) + const waiting = socket.whenReady(streams) + await jest.advanceTimersByTimeAsync(socket.config.timeout) await expect(waiting).resolves.toBe(false) }) + + describe('after a reopen', () => { + it('keeps entries but turns them Unconfirmed, so no sub frame is sent', async () => { + await subscribe('stream-notify-user', ['uid/media-signal']) + await subscribe('stream-notify-user', ['uid/media-calls']) + + const reopening = socket.reopenNow() + const reopened = fakeSockets[fakeSockets.length - 1] + await driveToHandshake(reopened, 'reopened-session') + await reopening + + const framesBefore = reopened.sent.length + const waiting = socket.whenReady(streams, 500) + await jest.advanceTimersByTimeAsync(500) + + expect(reopened.sent).toHaveLength(framesBefore) + await expect(waiting).resolves.toBe(false) + }) + + it('followed by login re-sends via subscribeAll and confirms on the new generation', async () => { + await subscribe('stream-notify-user', ['uid/media-signal']) + await subscribe('stream-notify-user', ['uid/media-calls']) + + const reopening = socket.reopenNow() + const reopened = fakeSockets[fakeSockets.length - 1] + await driveToHandshake(reopened, 'reopened-session') + await reopening + + const waiting = socket.whenReady(streams) + const framesBefore = reopened.sent.length + socket.subscribeAll() + await flushMicrotasks() + + expect(reopened.sent.slice(framesBefore).map((frame) => JSON.parse(frame))).toEqual([ + { msg: 'sub', id: 'ddp-1', name: 'stream-notify-user', params: ['uid/media-signal'] }, + { msg: 'sub', id: 'ddp-2', name: 'stream-notify-user', params: ['uid/media-calls'] } + ]) + + reopened.receive({ msg: 'ready', subs: ['ddp-1'] }) + reopened.receive({ msg: 'ready', subs: ['ddp-2'] }) + await expect(waiting).resolves.toBe(true) + }) + }) }) describe('a subscription a reopen abandoned', () => { diff --git a/lib/drivers/definitions.ts b/lib/drivers/definitions.ts index 4c8ce54..6b9077b 100644 --- a/lib/drivers/definitions.ts +++ b/lib/drivers/definitions.ts @@ -20,7 +20,7 @@ export interface ISocket { subscribeRaw (...args: any[]): Promise unsubscribe (subscription: ISubscription): Promise unsubscribeAll (): Promise - resubscribeWhenRecorded (streams: IStream[], timeoutMs?: number): Promise + whenReady (streams: IStream[], timeoutMs?: number): Promise onStreamData (event: string, cb: ICallback): Promise diff --git a/lib/drivers/driver.ts b/lib/drivers/driver.ts index 1ed52a1..9a1ba42 100644 --- a/lib/drivers/driver.ts +++ b/lib/drivers/driver.ts @@ -149,13 +149,11 @@ export class Driver extends SDKEventEmitter implements ISocket, IDriver { } /** - * Re-send the user's media-signal and media-calls subscriptions on the current - * Socket and resolve when the server acks them with `ready`. This gives the app - * an observable readiness signal after a forced reconnect. + * Resolve when the user's media-signal and media-calls streams are Confirmed + * subs on the current connection, or `false` when the Deadline rings first. * - * The Socket owns both the waiting and the re-sending: the re-send goes out under - * the ids the streams were first sent with, which this Driver's own `subscribe` - * would drop. + * This is a query over the Socket's recorded subscriptions; it sends no `sub` + * of its own. The re-send happens through `subscribeAll` on Login. */ waitForNotifyUserMediaSubs = (timeoutMs = this.ddp.config.timeout): Promise => { if (!this.userId) { @@ -163,7 +161,7 @@ export class Driver extends SDKEventEmitter implements ISocket, IDriver { } const topic = 'stream-notify-user' const userId = this.userId - return this.resubscribeWhenRecorded( + return this.whenReady( ['media-signal', 'media-calls'].map(name => ({ name: topic, params: [`${userId}/${name}`] })), timeoutMs ) @@ -199,14 +197,14 @@ export class Driver extends SDKEventEmitter implements ISocket, IDriver { return this.ddp.unsubscribe(subscription.id) } - resubscribeWhenRecorded = (streams: IStream[], timeoutMs?: number): Promise => { - return this.ddp.resubscribeWhenRecorded(streams, timeoutMs) - } - unsubscribeAll = (): Promise => { return this.ddp.unsubscribeAll() } + whenReady = (streams: IStream[], timeoutMs?: number): Promise => { + return this.ddp.whenReady(streams, timeoutMs) + } + onStreamData = (event: string, cb: ICallback): Promise => { function listener (message: any) { cb((message)) diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index f2304af..862140b 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -89,6 +89,8 @@ export class Socket extends SDKEventEmitter { private settleReopen?: () => void private pendingOpenRejects = new WeakMap void>() private subscriptionRequests: { [id: string]: Promise } = {} + private connectionGeneration = 0 + private readonly subscriptionConfirmedEvent = 'subscription:confirmed' /** Create a websocket handler */ constructor ( @@ -133,6 +135,7 @@ export class Socket extends SDKEventEmitter { this.logger.error(err) return reject(err) } + this.connectionGeneration += 1 // Tear down the previous connection before replacing it. // Callers only reach here when the existing socket isn't healthy, so // detaching its handlers and closing it stops a stale or still-connecting @@ -736,7 +739,7 @@ export class Socket extends SDKEventEmitter { return this.queueSubscriptionRequest(id, () => this.send({ msg: 'sub', id, name, params })) .then((result) => { const confirmedId = (result.subs) ? result.subs[0] : undefined - if (confirmedId) return this.rememberSubscription(confirmedId, name, params, callback) + if (confirmedId) return this.rememberSubscription(confirmedId, name, params, callback, true) }) .catch((err) => { this.logger.error(`[ddp] Subscribe error: ${err.message}`) @@ -760,14 +763,24 @@ export class Socket extends SDKEventEmitter { id: string, name: string, params: any[], - callback?: ISocketMessageCallback + callback?: ISocketMessageCallback, + confirmed?: boolean ) => { if (!this.connection) return const unsubscribe = this.unsubscribe.bind(this, id) const onEvent = this.onEvent.bind(this, name) - const subscription = { id, name, params, unsubscribe, onEvent } + const existing = this.subscriptions[id] + const confirmedOnGeneration = confirmed + ? this.connectionGeneration + : existing?.confirmedOnGeneration === this.connectionGeneration + ? this.connectionGeneration + : undefined + const subscription = { id, name, params, unsubscribe, onEvent, confirmedOnGeneration } if (callback) subscription.onEvent(callback) this.subscriptions[id] = subscription + if (confirmedOnGeneration === this.connectionGeneration) { + this.emit(this.subscriptionConfirmedEvent, subscription) + } return subscription } @@ -786,53 +799,47 @@ export class Socket extends SDKEventEmitter { )) /** - * Re-send the given streams on the current connection under the ids they were - * first sent with, and resolve on whether the server acked every one of them. - * - * Nothing goes out until every stream asked for is recorded here, so the - * deadline expiring first resolves false. + * The DDP subscriptions on this Socket for one stream name, matched exactly on + * the params given: same length and element-wise `===`. Prefix matching lives + * in `findSubscriptions` for its existing callers and is not used here. */ - resubscribeWhenRecorded = ( + private findConfirmedSubscription = ({ name, params = [] }: IStream): ISubscription | undefined => + Object.keys(this.subscriptions || {}) + .map((id) => this.subscriptions[id]) + .find((sub) => ( + sub && + sub.name === name && + sub.confirmedOnGeneration === this.connectionGeneration && + sub.params?.length === params.length && + params.every((param, index) => sub.params[index] === param) + )) + + /** + * Resolve when every stream asked for has a Confirmed sub on the current + * generation, or `false` when the Deadline rings first. Sends no `sub` of its + * own: it is a query over the recorded state. See ADR-0009. + */ + whenReady = ( streams: IStream[], timeoutMs = this.config.timeout ): Promise => { - const recordedPerStream = () => streams.map((stream) => this.findSubscriptions(stream)) - const resubscribe = (subs: ISubscription[]) => Promise.all( - subs.map((sub) => this.subscribe(sub.name, sub.params, undefined, sub.id)) - ) - .then((results) => { - const unacknowledged = subs.filter((_, index) => !results[index]) - unacknowledged.forEach((sub) => this.logger.error( - `[ddp] Subscribe not acknowledged: ${sub.params?.[0]}` - )) - return unacknowledged.length === 0 - }) - .catch(() => false) - return new Promise((resolve) => { let settled = false - let inFlight = false const finish = (value: boolean) => { if (settled) return settled = true - clearInterval(poll) clearTimeout(deadline) + this.off(this.subscriptionConfirmedEvent, check) resolve(value) } - const attempt = () => { - if (inFlight) return - const perStream = recordedPerStream() - if (!perStream.every((subs) => subs.length > 0)) return - inFlight = true - const recorded = perStream.reduce((all, subs) => all.concat(subs), [] as ISubscription[]) - resubscribe(recorded).then((value) => { - inFlight = false - finish(value) - }) + const check = () => { + if (streams.every((stream) => this.findConfirmedSubscription(stream))) { + finish(true) + } } const deadline = setTimeout(() => finish(false), timeoutMs) - const poll = setInterval(attempt, 100) - attempt() + this.on(this.subscriptionConfirmedEvent, check) + check() }) } From d7daa2871033a5e45e028cd705dfc04ce05ac7f4 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 17:40:53 -0300 Subject: [PATCH 02/21] refactor: drop the added comments and match confirmed subs through findSubscriptions --- fix | 0 .../__tests__/socket.subscriptions.spec.ts | 1 - lib/drivers/driver.ts | 7 ------- lib/drivers/socket.ts | 18 ++---------------- review | 0 5 files changed, 2 insertions(+), 24 deletions(-) create mode 100644 fix create mode 100644 review diff --git a/fix b/fix new file mode 100644 index 0000000..e69de29 diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index 0752600..a03ef03 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -216,7 +216,6 @@ describe('Socket subscription bookkeeping', () => { }) it('uses exact match: a same-name prefix params entry does not count', async () => { - // `findSubscriptions` matches on prefix; readiness must not. await subscribe('stream-notify-user', ['uid/media-signal', false]) const waiting = socket.whenReady(streams) diff --git a/lib/drivers/driver.ts b/lib/drivers/driver.ts index 9a1ba42..769e56c 100644 --- a/lib/drivers/driver.ts +++ b/lib/drivers/driver.ts @@ -148,13 +148,6 @@ export class Driver extends SDKEventEmitter implements ISocket, IDriver { ].map(event => this.subscribe(topic, `${this.userId}/${event}`, false))) } - /** - * Resolve when the user's media-signal and media-calls streams are Confirmed - * subs on the current connection, or `false` when the Deadline rings first. - * - * This is a query over the Socket's recorded subscriptions; it sends no `sub` - * of its own. The re-send happens through `subscribeAll` on Login. - */ waitForNotifyUserMediaSubs = (timeoutMs = this.ddp.config.timeout): Promise => { if (!this.userId) { return Promise.resolve(false) diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index 862140b..a568318 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -798,27 +798,13 @@ export class Socket extends SDKEventEmitter { params.every((param, index) => sub.params?.[index] === param) )) - /** - * The DDP subscriptions on this Socket for one stream name, matched exactly on - * the params given: same length and element-wise `===`. Prefix matching lives - * in `findSubscriptions` for its existing callers and is not used here. - */ private findConfirmedSubscription = ({ name, params = [] }: IStream): ISubscription | undefined => - Object.keys(this.subscriptions || {}) - .map((id) => this.subscriptions[id]) + this.findSubscriptions({ name, params }) .find((sub) => ( - sub && - sub.name === name && sub.confirmedOnGeneration === this.connectionGeneration && - sub.params?.length === params.length && - params.every((param, index) => sub.params[index] === param) + sub.params?.length === params.length )) - /** - * Resolve when every stream asked for has a Confirmed sub on the current - * generation, or `false` when the Deadline rings first. Sends no `sub` of its - * own: it is a query over the recorded state. See ADR-0009. - */ whenReady = ( streams: IStream[], timeoutMs = this.config.timeout diff --git a/review b/review new file mode 100644 index 0000000..e69de29 From 7d498b36dcf0b053bbc94085d2f53378b75c738e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 17:41:22 -0300 Subject: [PATCH 03/21] chore: untrack scratch files committed by mistake --- fix | 0 review | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 fix delete mode 100644 review diff --git a/fix b/fix deleted file mode 100644 index e69de29..0000000 diff --git a/review b/review deleted file mode 100644 index e69de29..0000000 From 797aebe03e8a4cfafb8c2ec2cd4f22ebf5dbf45f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 18:02:51 -0300 Subject: [PATCH 04/21] refactor: keep readiness in a Socket-owned set instead of a generation on the entry --- ...on-readiness-is-recorded-by-the-socket.md} | 23 +++++----- fix | 0 interfaces/index.ts | 1 - lib/drivers/__tests__/driver.spec.ts | 4 +- .../__tests__/socket.subscriptions.spec.ts | 4 +- lib/drivers/socket.ts | 45 +++++++++---------- review | 0 7 files changed, 37 insertions(+), 40 deletions(-) rename docs/adr/{0009-subscription-readiness-is-recorded-on-the-entry.md => 0009-subscription-readiness-is-recorded-by-the-socket.md} (84%) create mode 100644 fix create mode 100644 review diff --git a/docs/adr/0009-subscription-readiness-is-recorded-on-the-entry.md b/docs/adr/0009-subscription-readiness-is-recorded-by-the-socket.md similarity index 84% rename from docs/adr/0009-subscription-readiness-is-recorded-on-the-entry.md rename to docs/adr/0009-subscription-readiness-is-recorded-by-the-socket.md index 8c3be33..840cc08 100644 --- a/docs/adr/0009-subscription-readiness-is-recorded-on-the-entry.md +++ b/docs/adr/0009-subscription-readiness-is-recorded-by-the-socket.md @@ -1,4 +1,4 @@ -# ADR-0009: Subscription readiness is recorded on the entry, scoped to a connection +# ADR-0009: Subscription readiness is recorded by the Socket, scoped to a connection **Status:** Accepted @@ -43,15 +43,15 @@ and the reason has to be re-established first. It is tracked as #359. Readiness is recorded state; the query is derived; the query sends nothing. -- Readiness lives on the subscription entry, and the entry is the single - source of truth for 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 by a generation the Socket mints and - increments in `createConnection`. The entry stores the generation it was - confirmed on, and is a Confirmed sub only while that generation is the - current one. A reopen therefore turns every entry Unconfirmed without a pass - over `subscriptions` — the comparison does the forgetting. +- Readiness lives in a private set of confirmed subscription ids the Socket + owns, beside the entries; the entry itself stays a stream handle and the + public `ISubscription` carries no readiness field. `whenReady` is a thin + query over the entries and the set: 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: `createConnection` clears the set, so + a reopen turns every sub Unconfirmed without a pass over `subscriptions` — + the clear does the forgetting. - 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 @@ -82,8 +82,7 @@ Readiness is recorded state; the query is derived; the query sends nothing. 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` — they - match with `toMatchObject`, so the generation field breaks nothing. Tests +- 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 diff --git a/fix b/fix new file mode 100644 index 0000000..e69de29 diff --git a/interfaces/index.ts b/interfaces/index.ts index 95714c7..f38766c 100644 --- a/interfaces/index.ts +++ b/interfaces/index.ts @@ -184,7 +184,6 @@ export interface ISubscription { name?: any unsubscribe: () => Promise onEvent?: (callback: ISocketMessageCallback) => void - confirmedOnGeneration?: number [key: string]: any } diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index 6413554..66e52d7 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -123,7 +123,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { expect(jest.getTimerCount()).toBe(timersBefore) }) - it('resolves true when both media streams are confirmed on the current generation', async () => { + it('resolves true when both media streams are confirmed on the current connection', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId @@ -226,7 +226,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await expect(waiting).resolves.toBe(false) }) - it('resolves true after login re-sends and the server confirms on the new generation', async () => { + it('resolves true after login re-sends and the server confirms on the new connection', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index a03ef03..f7684f7 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -156,7 +156,7 @@ describe('Socket subscription bookkeeping', () => { { name: 'stream-notify-user', params: ['uid/media-calls'] } ] - it('resolves true once the streams are confirmed on the current generation', async () => { + it('resolves true once the streams are confirmed on the current connection', async () => { await subscribe('stream-notify-user', ['uid/media-signal']) const waiting = socket.whenReady(streams) @@ -242,7 +242,7 @@ describe('Socket subscription bookkeeping', () => { await expect(waiting).resolves.toBe(false) }) - it('followed by login re-sends via subscribeAll and confirms on the new generation', async () => { + it('followed by login re-sends via subscribeAll and confirms on the new connection', async () => { await subscribe('stream-notify-user', ['uid/media-signal']) await subscribe('stream-notify-user', ['uid/media-calls']) diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index a568318..5c87c97 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -89,8 +89,8 @@ export class Socket extends SDKEventEmitter { private settleReopen?: () => void private pendingOpenRejects = new WeakMap void>() private subscriptionRequests: { [id: string]: Promise } = {} - private connectionGeneration = 0 - private readonly subscriptionConfirmedEvent = 'subscription:confirmed' + private confirmedSubscriptions = new Set() + private readinessListeners = new Set<() => void>() /** Create a websocket handler */ constructor ( @@ -135,7 +135,7 @@ export class Socket extends SDKEventEmitter { this.logger.error(err) return reject(err) } - this.connectionGeneration += 1 + this.confirmedSubscriptions.clear() // Tear down the previous connection before replacing it. // Callers only reach here when the existing socket isn't healthy, so // detaching its handlers and closing it stops a stale or still-connecting @@ -348,6 +348,7 @@ export class Socket extends SDKEventEmitter { /** Drop one DDP subscription. */ forgetSubscription = (id: string) => { + this.confirmedSubscriptions.delete(id) delete this.subscriptions[id] } @@ -738,13 +739,16 @@ export class Socket extends SDKEventEmitter { this.logger.info(`[ddp] Subscribe to ${name}, param: ${JSON.stringify(params)}`) return this.queueSubscriptionRequest(id, () => this.send({ msg: 'sub', id, name, params })) .then((result) => { - const confirmedId = (result.subs) ? result.subs[0] : undefined - if (confirmedId) return this.rememberSubscription(confirmedId, name, params, callback, true) + const confirmedId = result.subs?.[0] + if (!confirmedId) return undefined + const subscription = this.recordSubscription(confirmedId, name, params, callback) + if (subscription) this.confirmSubscription(confirmedId) + return subscription }) .catch((err) => { this.logger.error(`[ddp] Subscribe error: ${err.message}`) if (err instanceof AbandonedRequest || err instanceof ExpiredWait) { - this.rememberSubscription(err.id, name, params, callback) + this.recordSubscription(err.id, name, params, callback) } else if (id && err instanceof DDPError) { this.forgetSubscription(id) } @@ -759,31 +763,26 @@ export class Socket extends SDKEventEmitter { * locally and sends no `unsub`: closing the connection ends the streams on * the server. */ - private rememberSubscription = ( + private recordSubscription = ( id: string, name: string, params: any[], - callback?: ISocketMessageCallback, - confirmed?: boolean + callback?: ISocketMessageCallback ) => { if (!this.connection) return const unsubscribe = this.unsubscribe.bind(this, id) const onEvent = this.onEvent.bind(this, name) - const existing = this.subscriptions[id] - const confirmedOnGeneration = confirmed - ? this.connectionGeneration - : existing?.confirmedOnGeneration === this.connectionGeneration - ? this.connectionGeneration - : undefined - const subscription = { id, name, params, unsubscribe, onEvent, confirmedOnGeneration } + const subscription = { id, name, params, unsubscribe, onEvent } if (callback) subscription.onEvent(callback) this.subscriptions[id] = subscription - if (confirmedOnGeneration === this.connectionGeneration) { - this.emit(this.subscriptionConfirmedEvent, subscription) - } return subscription } + private confirmSubscription = (id: string) => { + this.confirmedSubscriptions.add(id) + this.readinessListeners.forEach((check) => check()) + } + /** * The DDP subscriptions on this Socket for one stream name, matched on the * params given. `subscriptions` is keyed by DDP subscription id, so a caller @@ -801,8 +800,8 @@ export class Socket extends SDKEventEmitter { private findConfirmedSubscription = ({ name, params = [] }: IStream): ISubscription | undefined => this.findSubscriptions({ name, params }) .find((sub) => ( - sub.confirmedOnGeneration === this.connectionGeneration && - sub.params?.length === params.length + sub.params?.length === params.length && + this.confirmedSubscriptions.has(sub.id as string) )) whenReady = ( @@ -815,7 +814,7 @@ export class Socket extends SDKEventEmitter { if (settled) return settled = true clearTimeout(deadline) - this.off(this.subscriptionConfirmedEvent, check) + this.readinessListeners.delete(check) resolve(value) } const check = () => { @@ -824,7 +823,7 @@ export class Socket extends SDKEventEmitter { } } const deadline = setTimeout(() => finish(false), timeoutMs) - this.on(this.subscriptionConfirmedEvent, check) + this.readinessListeners.add(check) check() }) } diff --git a/review b/review new file mode 100644 index 0000000..e69de29 From 3b4307e80e8ca90de05eebf079f7463a1ab84130 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 18:03:14 -0300 Subject: [PATCH 05/21] chore: untrack scratch files committed by mistake --- fix | 0 review | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 fix delete mode 100644 review diff --git a/fix b/fix deleted file mode 100644 index e69de29..0000000 diff --git a/review b/review deleted file mode 100644 index e69de29..0000000 From bc8ff23968fa3709b0ee9d7d18b90c851bef0f08 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 10:50:42 -0300 Subject: [PATCH 06/21] fix: turn every sub Unconfirmed the moment the connection closes --- ...ion-readiness-is-recorded-by-the-socket.md | 7 +++-- lib/drivers/__tests__/driver.spec.ts | 21 +++++++------- .../__tests__/socket.subscriptions.spec.ts | 29 ++++++++++++++----- lib/drivers/socket.ts | 1 + 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/adr/0009-subscription-readiness-is-recorded-by-the-socket.md b/docs/adr/0009-subscription-readiness-is-recorded-by-the-socket.md index 840cc08..d8bad1f 100644 --- a/docs/adr/0009-subscription-readiness-is-recorded-by-the-socket.md +++ b/docs/adr/0009-subscription-readiness-is-recorded-by-the-socket.md @@ -49,9 +49,10 @@ Readiness is recorded state; the query is derived; the query sends nothing. query over the entries and the set: 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: `createConnection` clears the set, so - a reopen turns every sub Unconfirmed without a pass over `subscriptions` — - the clear does the forgetting. +- Readiness is scoped to a connection: `onClose` clears the set the moment + the connection ends and `createConnection` clears it again for the new + one, so a reopen turns every sub Unconfirmed without a pass over + `subscriptions` — the clear does the forgetting. - 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 diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index 66e52d7..84a2679 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -111,6 +111,15 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const addMediaSub = (driver: Driver, transport: FakeWebSocket, name: string) => addSub(driver, transport, `${userId}/${name}`, `sub-${name}`) + const reopenAndHandshake = async (driver: Driver, previous: FakeWebSocket) => { + const reopening = driver.reopenNow() + const reopened = fakeSockets[fakeSockets.length - 1] + expect(reopened).not.toBe(previous) + await driveToHandshake(reopened, 'reopened-session') + await reopening + return reopened + } + it('resolves false without a logged-in user, before scheduling anything', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) @@ -212,11 +221,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await addMediaSub(driver, transport, 'media-signal') await addMediaSub(driver, transport, 'media-calls') - const reopening = driver.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - expect(reopened).not.toBe(transport) - await driveToHandshake(reopened, 'reopened-session') - await reopening + const reopened = await reopenAndHandshake(driver, transport) const sentBefore = reopened.sent.length const waiting = driver.waitForNotifyUserMediaSubs(500) @@ -233,11 +238,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await addMediaSub(driver, transport, 'media-signal') await addMediaSub(driver, transport, 'media-calls') - const reopening = driver.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - expect(reopened).not.toBe(transport) - await driveToHandshake(reopened, 'reopened-session') - await reopening + const reopened = await reopenAndHandshake(driver, transport) const waiting = driver.waitForNotifyUserMediaSubs() const framesBefore = reopened.sent.length diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index f7684f7..110e43f 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -44,6 +44,14 @@ describe('Socket subscription bookkeeping', () => { return subscribing } + const reopenAndHandshake = async () => { + const reopening = socket.reopenNow() + const reopened = fakeSockets[fakeSockets.length - 1] + await driveToHandshake(reopened, 'reopened-session') + await reopening + return reopened + } + it('keys a subscription by the id the server acknowledged', async () => { await subscribe('stream-room-messages', ['GENERAL']) @@ -229,10 +237,7 @@ describe('Socket subscription bookkeeping', () => { await subscribe('stream-notify-user', ['uid/media-signal']) await subscribe('stream-notify-user', ['uid/media-calls']) - const reopening = socket.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - await driveToHandshake(reopened, 'reopened-session') - await reopening + const reopened = await reopenAndHandshake() const framesBefore = reopened.sent.length const waiting = socket.whenReady(streams, 500) @@ -246,10 +251,7 @@ describe('Socket subscription bookkeeping', () => { await subscribe('stream-notify-user', ['uid/media-signal']) await subscribe('stream-notify-user', ['uid/media-calls']) - const reopening = socket.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - await driveToHandshake(reopened, 'reopened-session') - await reopening + const reopened = await reopenAndHandshake() const waiting = socket.whenReady(streams) const framesBefore = reopened.sent.length @@ -266,6 +268,17 @@ describe('Socket subscription bookkeeping', () => { await expect(waiting).resolves.toBe(true) }) }) + + it('turns every sub Unconfirmed the moment the connection closes, before a new one exists', async () => { + await subscribe('stream-notify-user', ['uid/media-signal']) + await subscribe('stream-notify-user', ['uid/media-calls']) + + transport.close() + + const waiting = socket.whenReady(streams, 500) + await jest.advanceTimersByTimeAsync(500) + await expect(waiting).resolves.toBe(false) + }) }) describe('a subscription a reopen abandoned', () => { diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index 5c87c97..cd54e27 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -205,6 +205,7 @@ export class Socket extends SDKEventEmitter { if (closedConnection && closedConnection !== this.connection) { return } + this.confirmedSubscriptions.clear() this.emit('close', e) try { if (e?.code !== userDisconnectCloseCode) { From 017e8108c6a746933fdd384c2c56a0d9138c0cbf Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 10:58:56 -0300 Subject: [PATCH 07/21] refactor: name the confirmed-id set for what it holds --- lib/drivers/socket.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index cd54e27..e959d1e 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -89,7 +89,7 @@ export class Socket extends SDKEventEmitter { private settleReopen?: () => void private pendingOpenRejects = new WeakMap void>() private subscriptionRequests: { [id: string]: Promise } = {} - private confirmedSubscriptions = new Set() + private confirmedSubscriptionIds = new Set() private readinessListeners = new Set<() => void>() /** Create a websocket handler */ @@ -135,7 +135,7 @@ export class Socket extends SDKEventEmitter { this.logger.error(err) return reject(err) } - this.confirmedSubscriptions.clear() + this.confirmedSubscriptionIds.clear() // Tear down the previous connection before replacing it. // Callers only reach here when the existing socket isn't healthy, so // detaching its handlers and closing it stops a stale or still-connecting @@ -205,7 +205,7 @@ export class Socket extends SDKEventEmitter { if (closedConnection && closedConnection !== this.connection) { return } - this.confirmedSubscriptions.clear() + this.confirmedSubscriptionIds.clear() this.emit('close', e) try { if (e?.code !== userDisconnectCloseCode) { @@ -349,7 +349,7 @@ export class Socket extends SDKEventEmitter { /** Drop one DDP subscription. */ forgetSubscription = (id: string) => { - this.confirmedSubscriptions.delete(id) + this.confirmedSubscriptionIds.delete(id) delete this.subscriptions[id] } @@ -780,7 +780,7 @@ export class Socket extends SDKEventEmitter { } private confirmSubscription = (id: string) => { - this.confirmedSubscriptions.add(id) + this.confirmedSubscriptionIds.add(id) this.readinessListeners.forEach((check) => check()) } @@ -802,7 +802,7 @@ export class Socket extends SDKEventEmitter { this.findSubscriptions({ name, params }) .find((sub) => ( sub.params?.length === params.length && - this.confirmedSubscriptions.has(sub.id as string) + this.confirmedSubscriptionIds.has(sub.id as string) )) whenReady = ( From c007c47560e9c6e702227b9fe1f8419deeb5790e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 11:06:52 -0300 Subject: [PATCH 08/21] refactor: spell out subscription in the driver spec helpers --- lib/drivers/__tests__/driver.spec.ts | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index 84a2679..205f3e2 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -101,15 +101,15 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const topic = 'stream-notify-user' /** Register a subscription on the Socket under a given id, as a successful sub would. */ - const addSub = async (driver: Driver, transport: FakeWebSocket, event: string, id: string) => { + const addSubscription = async (driver: Driver, transport: FakeWebSocket, event: string, id: string) => { const subscribing = driver.ddp.subscribe(topic, [event], undefined, id) transport.receive({ msg: 'ready', subs: [id] }) await subscribing } /** Register a media subscription on the Socket, as a successful sub would. */ - const addMediaSub = (driver: Driver, transport: FakeWebSocket, name: string) => - addSub(driver, transport, `${userId}/${name}`, `sub-${name}`) + const addMediaSubscription = (driver: Driver, transport: FakeWebSocket, name: string) => + addSubscription(driver, transport, `${userId}/${name}`, `sub-${name}`) const reopenAndHandshake = async (driver: Driver, previous: FakeWebSocket) => { const reopening = driver.reopenNow() @@ -136,7 +136,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') + await addMediaSubscription(driver, transport, 'media-signal') const waiting = driver.waitForNotifyUserMediaSubs() let resolved: boolean | undefined @@ -145,7 +145,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await jest.advanceTimersByTimeAsync(1) expect(resolved).toBeUndefined() - await addMediaSub(driver, transport, 'media-calls') + await addMediaSubscription(driver, transport, 'media-calls') await expect(waiting).resolves.toBe(true) }) @@ -191,8 +191,8 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId - await addSub(driver, transport, 'other-user/media-signal', 'sub-other-signal') - await addSub(driver, transport, 'other-user/media-calls', 'sub-other-calls') + await addSubscription(driver, transport, 'other-user/media-signal', 'sub-other-signal') + await addSubscription(driver, transport, 'other-user/media-calls', 'sub-other-calls') const waiting = driver.waitForNotifyUserMediaSubs(500) @@ -205,9 +205,9 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId - await addSub(driver, transport, `${userId}/message`, 'sub-message') - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') + await addSubscription(driver, transport, `${userId}/message`, 'sub-message') + await addMediaSubscription(driver, transport, 'media-signal') + await addMediaSubscription(driver, transport, 'media-calls') const waiting = driver.waitForNotifyUserMediaSubs() await expect(waiting).resolves.toBe(true) @@ -218,8 +218,8 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') + await addMediaSubscription(driver, transport, 'media-signal') + await addMediaSubscription(driver, transport, 'media-calls') const reopened = await reopenAndHandshake(driver, transport) @@ -235,8 +235,8 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') + await addMediaSubscription(driver, transport, 'media-signal') + await addMediaSubscription(driver, transport, 'media-calls') const reopened = await reopenAndHandshake(driver, transport) @@ -289,8 +289,8 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) driver.userId = userId - await addMediaSub(driver, transport, 'media-signal') - await addMediaSub(driver, transport, 'media-calls') + await addMediaSubscription(driver, transport, 'media-signal') + await addMediaSubscription(driver, transport, 'media-calls') const timersBefore = jest.getTimerCount() From bed66f8f3d9ee9f32827d43a81a8e26cc137c211 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 11:31:49 -0300 Subject: [PATCH 09/21] refactor: name the readiness listener for what it decides --- CONTEXT.md | 6 ++++++ ...d-by-a-forced-reconnect-keeps-its-entry.md | 12 +++++------ lib/drivers/__tests__/driver.spec.ts | 10 +--------- .../__tests__/socket.subscriptions.spec.ts | 14 ++++--------- lib/drivers/socket.ts | 13 ++++++------ test/fakeTransport.ts | 20 +++++++++++++++++++ 6 files changed, 44 insertions(+), 31 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 0a1e392..fa8fbbc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -82,6 +82,12 @@ _Avoid_: Active subscription, live 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 diff --git a/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md b/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md index 4a96eab..e03baa1 100644 --- a/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md +++ b/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md @@ -99,12 +99,12 @@ The server's answer decides; silence keeps the instruction. before, and send `unsub` frames for them. Both already tolerate a server that refuses: `unsubscribeAll` catches each failure, and `close` forgets everything regardless. -- `Socket.resubscribeWhenRecorded`, behind `Driver.waitForNotifyUserMediaSubs`, - polls `subscriptions` for the two media entries, and an entry written on an - abandoned `sub` now ends that poll where it would previously have kept 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-0009 replaces the mechanism behind that gate with + `Socket.whenReady`, which answers from the recorded confirmations instead of + from the presence of an entry. - ADR-0004's remaining open question is untouched: whether a `sub` may be sent for an id whose `unsub` is still in flight is still not settled, and the behaviour of the server in that case is still not known. This ADR does make diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index 205f3e2..e35f39e 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -8,6 +8,7 @@ import { fakeSockets, flushMicrotasks, openFakeConnection, + reopenAndHandshake, useFakeClockAndSocketRegistry } from '../../../test/fakeTransport' @@ -111,15 +112,6 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const addMediaSubscription = (driver: Driver, transport: FakeWebSocket, name: string) => addSubscription(driver, transport, `${userId}/${name}`, `sub-${name}`) - const reopenAndHandshake = async (driver: Driver, previous: FakeWebSocket) => { - const reopening = driver.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - expect(reopened).not.toBe(previous) - await driveToHandshake(reopened, 'reopened-session') - await reopening - return reopened - } - it('resolves false without a logged-in user, before scheduling anything', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index 110e43f..b0b9557 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -7,6 +7,7 @@ import { fakeSockets, driveToHandshake, openFakeConnection, + reopenAndHandshake, useFakeClockAndSocketRegistry } from '../../../test/fakeTransport' @@ -44,14 +45,6 @@ describe('Socket subscription bookkeeping', () => { return subscribing } - const reopenAndHandshake = async () => { - const reopening = socket.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] - await driveToHandshake(reopened, 'reopened-session') - await reopening - return reopened - } - it('keys a subscription by the id the server acknowledged', async () => { await subscribe('stream-room-messages', ['GENERAL']) @@ -237,13 +230,14 @@ describe('Socket subscription bookkeeping', () => { await subscribe('stream-notify-user', ['uid/media-signal']) await subscribe('stream-notify-user', ['uid/media-calls']) - const reopened = await reopenAndHandshake() + const reopened = await reopenAndHandshake(socket) const framesBefore = reopened.sent.length const waiting = socket.whenReady(streams, 500) await jest.advanceTimersByTimeAsync(500) expect(reopened.sent).toHaveLength(framesBefore) + expect(Object.keys(socket.subscriptions)).toEqual(['ddp-1', 'ddp-2']) await expect(waiting).resolves.toBe(false) }) @@ -251,7 +245,7 @@ describe('Socket subscription bookkeeping', () => { await subscribe('stream-notify-user', ['uid/media-signal']) await subscribe('stream-notify-user', ['uid/media-calls']) - const reopened = await reopenAndHandshake() + const reopened = await reopenAndHandshake(socket) const waiting = socket.whenReady(streams) const framesBefore = reopened.sent.length diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index e959d1e..95dd2f5 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -781,7 +781,7 @@ export class Socket extends SDKEventEmitter { private confirmSubscription = (id: string) => { this.confirmedSubscriptionIds.add(id) - this.readinessListeners.forEach((check) => check()) + this.readinessListeners.forEach((listener) => listener()) } /** @@ -802,7 +802,8 @@ export class Socket extends SDKEventEmitter { this.findSubscriptions({ name, params }) .find((sub) => ( sub.params?.length === params.length && - this.confirmedSubscriptionIds.has(sub.id as string) + sub.id !== undefined && + this.confirmedSubscriptionIds.has(sub.id) )) whenReady = ( @@ -815,17 +816,17 @@ export class Socket extends SDKEventEmitter { if (settled) return settled = true clearTimeout(deadline) - this.readinessListeners.delete(check) + this.readinessListeners.delete(resolveIfConfirmed) resolve(value) } - const check = () => { + const resolveIfConfirmed = () => { if (streams.every((stream) => this.findConfirmedSubscription(stream))) { finish(true) } } const deadline = setTimeout(() => finish(false), timeoutMs) - this.readinessListeners.add(check) - check() + this.readinessListeners.add(resolveIfConfirmed) + resolveIfConfirmed() }) } diff --git a/test/fakeTransport.ts b/test/fakeTransport.ts index 0ca12b4..53a241a 100644 --- a/test/fakeTransport.ts +++ b/test/fakeTransport.ts @@ -202,3 +202,23 @@ export const driveToHandshake = async (transport: FakeWebSocket, session = 'fake export const flushMicrotasks = async (): Promise => { for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() } + +/** + * Force a reconnect and drive the socket it builds through open and handshake. + * + * The reopen constructs its socket behind a promise the spec never gets to + * hold, so the new fake is read out of the registry; passing `previous` pins + * that a replacement was really built rather than the old one reused. + */ +export const reopenAndHandshake = async ( + socket: Pick, + previous?: FakeWebSocket, + session = 'reopened-session' +): Promise => { + const reopening = socket.reopenNow() + const reopened = fakeSockets[fakeSockets.length - 1] + if (previous) expect(reopened).not.toBe(previous) + await driveToHandshake(reopened, session) + await reopening + return reopened +} From 80cb2680e14d2593bcd1d692da0c6bc497cb9e46 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 11:36:59 -0300 Subject: [PATCH 10/21] refactor: ask the socket whether a stream has a confirmed sub --- CONTEXT.md | 4 +- lib/drivers/__tests__/driver.spec.ts | 37 +++++++++++++++++-- .../__tests__/socket.subscriptions.spec.ts | 7 ++-- lib/drivers/socket.ts | 6 +-- test/fakeTransport.ts | 11 ++---- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index fa8fbbc..521ed4b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,9 +83,7 @@ A recorded DDP subscription that is not a Confirmed sub — its `ready` never ar _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. +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**: diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index e35f39e..a57cb17 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -7,6 +7,7 @@ import { FakeWebSocket, fakeSockets, flushMicrotasks, + mostRecentFakeSocket, openFakeConnection, reopenAndHandshake, useFakeClockAndSocketRegistry @@ -132,7 +133,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const waiting = driver.waitForNotifyUserMediaSubs() let resolved: boolean | undefined - waiting.then((value: boolean) => { resolved = value }) + waiting.then((value) => { resolved = value }) await jest.advanceTimersByTimeAsync(1) expect(resolved).toBeUndefined() @@ -163,7 +164,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const waiting = driver.waitForNotifyUserMediaSubs() let resolved: boolean | undefined - waiting.then((value: boolean) => { resolved = value }) + waiting.then((value) => { resolved = value }) driver.ddp.subscribe(topic, [`${userId}/media-signal`], undefined, 'sub-media-signal') await jest.advanceTimersByTimeAsync(1) @@ -205,6 +206,18 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await expect(waiting).resolves.toBe(true) }) + it('resolves true when one of two entries recorded for the same stream is confirmed', async () => { + const driver = createDriver() + const transport = await openFakeConnection(driver.ddp) + driver.userId = userId + await addMediaSubscription(driver, transport, 'media-signal') + driver.ddp.subscribe(topic, [`${userId}/media-signal`], undefined, 'sub-media-signal-again') + transport.receive({ msg: 'nosub', id: 'sub-media-signal-again' }) + await addMediaSubscription(driver, transport, 'media-calls') + + await expect(driver.waitForNotifyUserMediaSubs()).resolves.toBe(true) + }) + describe('after a reopen', () => { it('resolves false at the deadline when no login re-confirms the entries', async () => { const driver = createDriver() @@ -223,6 +236,24 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await expect(waiting).resolves.toBe(false) }) + it('resolves true when the streams only land on the connection a reopen is still building', async () => { + const driver = createDriver() + await openFakeConnection(driver.ddp) + driver.userId = userId + + const reopening = driver.reopenNow() + const reopened = mostRecentFakeSocket() + const waiting = driver.waitForNotifyUserMediaSubs() + + await driveToHandshake(reopened, 'reopened-session') + await reopening + + await addMediaSubscription(driver, reopened, 'media-signal') + await addMediaSubscription(driver, reopened, 'media-calls') + + await expect(waiting).resolves.toBe(true) + }) + it('resolves true after login re-sends and the server confirms on the new connection', async () => { const driver = createDriver() const transport = await openFakeConnection(driver.ddp) @@ -268,7 +299,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const waiting = driver.waitForNotifyUserMediaSubs() let resolved: boolean | undefined - waiting.then((value: boolean) => { resolved = value }) + waiting.then((value) => { resolved = value }) await jest.advanceTimersByTimeAsync(timeout - 1) expect(resolved).toBeUndefined() diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index b0b9557..c1dde15 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -162,9 +162,8 @@ describe('Socket subscription bookkeeping', () => { const waiting = socket.whenReady(streams) let resolved: boolean | undefined - waiting.then((value: boolean) => { resolved = value }) + waiting.then((value) => { resolved = value }) - // One of the two is not enough. await jest.advanceTimersByTimeAsync(1) expect(resolved).toBeUndefined() @@ -175,7 +174,7 @@ describe('Socket subscription bookkeeping', () => { it('waits when no entry exists yet', async () => { const waiting = socket.whenReady(streams) let resolved: boolean | undefined - waiting.then((value: boolean) => { resolved = value }) + waiting.then((value) => { resolved = value }) socket.subscribe('stream-notify-user', ['uid/media-signal']) await jest.advanceTimersByTimeAsync(1) @@ -207,7 +206,7 @@ describe('Socket subscription bookkeeping', () => { it('takes its deadline from the configured timeout when given none', async () => { const waiting = socket.whenReady(streams) let resolved: boolean | undefined - waiting.then((value: boolean) => { resolved = value }) + waiting.then((value) => { resolved = value }) await jest.advanceTimersByTimeAsync(socket.config.timeout - 1) expect(resolved).toBeUndefined() diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index 95dd2f5..cf71f3d 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -798,9 +798,9 @@ export class Socket extends SDKEventEmitter { params.every((param, index) => sub.params?.[index] === param) )) - private findConfirmedSubscription = ({ name, params = [] }: IStream): ISubscription | undefined => + private hasConfirmedSubscription = ({ name, params = [] }: IStream): boolean => this.findSubscriptions({ name, params }) - .find((sub) => ( + .some((sub) => ( sub.params?.length === params.length && sub.id !== undefined && this.confirmedSubscriptionIds.has(sub.id) @@ -820,7 +820,7 @@ export class Socket extends SDKEventEmitter { resolve(value) } const resolveIfConfirmed = () => { - if (streams.every((stream) => this.findConfirmedSubscription(stream))) { + if (streams.every(this.hasConfirmedSubscription)) { finish(true) } } diff --git a/test/fakeTransport.ts b/test/fakeTransport.ts index 53a241a..e5d2854 100644 --- a/test/fakeTransport.ts +++ b/test/fakeTransport.ts @@ -31,6 +31,8 @@ export const USER_DISCONNECT = 4000 */ export const fakeSockets: FakeWebSocket[] = [] +export const mostRecentFakeSocket = (): FakeWebSocket => fakeSockets[fakeSockets.length - 1] + /** * A real class, not a three-property stub: `readyState` is read-only on the * declared transport type, and a mocked class is what lets a spec move it @@ -203,20 +205,13 @@ export const flushMicrotasks = async (): Promise => { for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() } -/** - * Force a reconnect and drive the socket it builds through open and handshake. - * - * The reopen constructs its socket behind a promise the spec never gets to - * hold, so the new fake is read out of the registry; passing `previous` pins - * that a replacement was really built rather than the old one reused. - */ export const reopenAndHandshake = async ( socket: Pick, previous?: FakeWebSocket, session = 'reopened-session' ): Promise => { const reopening = socket.reopenNow() - const reopened = fakeSockets[fakeSockets.length - 1] + const reopened = mostRecentFakeSocket() if (previous) expect(reopened).not.toBe(previous) await driveToHandshake(reopened, session) await reopening From 7a86269f02598a8e9215603149e80afdc1ce41fc Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 11:42:05 -0300 Subject: [PATCH 11/21] test: really record two entries for one stream in the readiness spec --- lib/drivers/__tests__/driver.spec.ts | 14 +++++++++++--- lib/drivers/__tests__/socket.subscriptions.spec.ts | 12 ++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index a57cb17..d24ad6f 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -211,11 +211,19 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { const transport = await openFakeConnection(driver.ddp) driver.userId = userId await addMediaSubscription(driver, transport, 'media-signal') - driver.ddp.subscribe(topic, [`${userId}/media-signal`], undefined, 'sub-media-signal-again') - transport.receive({ msg: 'nosub', id: 'sub-media-signal-again' }) await addMediaSubscription(driver, transport, 'media-calls') - await expect(driver.waitForNotifyUserMediaSubs()).resolves.toBe(true) + const unanswered = driver.ddp.subscribe(topic, [`${userId}/media-signal`], undefined, 'sub-media-signal-again') + await jest.advanceTimersByTimeAsync(driver.config.timeout) + await unanswered + + expect(Object.keys(driver.ddp.subscriptions)).toEqual( + expect.arrayContaining(['sub-media-signal', 'sub-media-signal-again']) + ) + + const waiting = driver.waitForNotifyUserMediaSubs(500) + await jest.advanceTimersByTimeAsync(500) + await expect(waiting).resolves.toBe(true) }) describe('after a reopen', () => { diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index c1dde15..5559d89 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -4,8 +4,6 @@ import { CLOSED, FakeWebSocket, flushMicrotasks, - fakeSockets, - driveToHandshake, openFakeConnection, reopenAndHandshake, useFakeClockAndSocketRegistry @@ -296,12 +294,9 @@ describe('Socket subscription bookkeeping', () => { it('is re-established under that same id at the next login', async () => { const subscribing = socket.subscribe('stream-room-messages', ['GENERAL']) - socket.reopenNow() + const reopened = await reopenAndHandshake(socket) await subscribing - const reopened = fakeSockets[1] - await driveToHandshake(reopened) - const framesBefore = reopened.sent.length socket.subscribeAll() await flushMicrotasks() @@ -316,12 +311,9 @@ describe('Socket subscription bookkeeping', () => { it('can be unsubscribed from, unlike one that was never written', async () => { const subscribing = socket.subscribe('stream-room-messages', ['GENERAL']) - socket.reopenNow() + const reopened = await reopenAndHandshake(socket) await subscribing - const reopened = fakeSockets[1] - await driveToHandshake(reopened) - // Nothing to await: the point is that the `unsub` goes out at all. Without // the entry, `unsubscribe` rejects up front and never reaches the wire. const unsubscribing = socket.unsubscribe('ddp-1').catch((err) => err) From 2e9687cbb7669eea3bdf4065c32dd05072108759 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 12:09:57 -0300 Subject: [PATCH 12/21] refactor: match confirmed subs exactly in one predicate --- lib/drivers/__tests__/driver.spec.ts | 4 ++-- lib/drivers/socket.ts | 22 ++++++++++++---------- test/fakeTransport.ts | 2 -- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index ef42e10..e6cd750 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -234,7 +234,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await addMediaSubscription(driver, transport, 'media-signal') await addMediaSubscription(driver, transport, 'media-calls') - const reopened = await reopenAndHandshake(driver, transport) + const reopened = await reopenAndHandshake(driver) const sentBefore = reopened.sent.length const waiting = driver.waitForNotifyUserMediaSubs(500) @@ -269,7 +269,7 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await addMediaSubscription(driver, transport, 'media-signal') await addMediaSubscription(driver, transport, 'media-calls') - const reopened = await reopenAndHandshake(driver, transport) + const reopened = await reopenAndHandshake(driver) const waiting = driver.waitForNotifyUserMediaSubs() const framesBefore = reopened.sent.length diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index bcc923a..2b8c5e2 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -804,23 +804,26 @@ export class Socket extends SDKEventEmitter { params.every((param, index) => sub.params?.[index] === param) )) - private hasConfirmedSubscription = ({ name, params = [] }: IStream): boolean => - this.findSubscriptions({ name, params }) - .some((sub) => ( + private hasConfirmedSubscription = ({ name, params = [] }: IStream): boolean => { + for (const id of this.confirmedSubscriptionIds) { + const sub = this.subscriptions[id] + if ( + sub && + sub.name === name && sub.params?.length === params.length && - sub.id !== undefined && - this.confirmedSubscriptionIds.has(sub.id) - )) + params.every((param, index) => sub.params?.[index] === param) + ) return true + } + return false + } whenReady = ( streams: IStream[], timeoutMs = this.config.timeout ): Promise => { + if (streams.every(this.hasConfirmedSubscription)) return Promise.resolve(true) return new Promise((resolve) => { - let settled = false const finish = (value: boolean) => { - if (settled) return - settled = true clearTimeout(deadline) this.readinessListeners.delete(resolveIfConfirmed) resolve(value) @@ -832,7 +835,6 @@ export class Socket extends SDKEventEmitter { } const deadline = setTimeout(() => finish(false), timeoutMs) this.readinessListeners.add(resolveIfConfirmed) - resolveIfConfirmed() }) } diff --git a/test/fakeTransport.ts b/test/fakeTransport.ts index e5d2854..ece32ec 100644 --- a/test/fakeTransport.ts +++ b/test/fakeTransport.ts @@ -207,12 +207,10 @@ export const flushMicrotasks = async (): Promise => { export const reopenAndHandshake = async ( socket: Pick, - previous?: FakeWebSocket, session = 'reopened-session' ): Promise => { const reopening = socket.reopenNow() const reopened = mostRecentFakeSocket() - if (previous) expect(reopened).not.toBe(previous) await driveToHandshake(reopened, session) await reopening return reopened From 8b47a8eabbd0100ae1e05fc2233bc2abb2dd642f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 12:23:58 -0300 Subject: [PATCH 13/21] refactor: use the glossary terms in ADR-0011 and drop the duplicated timeout default --- ...11-subscription-readiness-is-recorded-by-the-socket.md | 8 ++++---- lib/drivers/__tests__/socket.subscriptions.spec.ts | 8 ++++---- lib/drivers/driver.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md index fb9debd..9629f69 100644 --- a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md +++ b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md @@ -9,10 +9,10 @@ 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 live again before it answers. What it has today is +`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 acknowledged. +on whether the re-send was confirmed. Three properties of that mechanism fail the caller. @@ -30,8 +30,8 @@ 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 ack of the -mechanism's own re-send. Once readiness is recorded, the signal is the record, +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 diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index 5559d89..ad9d3f1 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -178,14 +178,14 @@ describe('Socket subscription bookkeeping', () => { await jest.advanceTimersByTimeAsync(1) expect(resolved).toBeUndefined() - const { id } = transport.lastSent() - transport.receive({ msg: 'ready', subs: [id] }) + const { id: signalId } = transport.lastSent() + transport.receive({ msg: 'ready', subs: [signalId] }) await jest.advanceTimersByTimeAsync(1) socket.subscribe('stream-notify-user', ['uid/media-calls']) await jest.advanceTimersByTimeAsync(1) - const { id: id2 } = transport.lastSent() - transport.receive({ msg: 'ready', subs: [id2] }) + const { id: callsId } = transport.lastSent() + transport.receive({ msg: 'ready', subs: [callsId] }) await expect(waiting).resolves.toBe(true) }) diff --git a/lib/drivers/driver.ts b/lib/drivers/driver.ts index 3e8a129..d823f60 100644 --- a/lib/drivers/driver.ts +++ b/lib/drivers/driver.ts @@ -148,7 +148,7 @@ export class Driver extends SDKEventEmitter implements ISocket, IDriver { ].map(event => this.subscribe(topic, `${this.userId}/${event}`, false))) } - waitForNotifyUserMediaSubs = (timeoutMs = this.socket.config.timeout): Promise => { + waitForNotifyUserMediaSubs = (timeoutMs?: number): Promise => { if (!this.userId) { return Promise.resolve(false) } From fff812c42d63774f34d774a3af9a8f1936efa513 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 12:32:28 -0300 Subject: [PATCH 14/21] refactor: call a nosub carrying a DDP error a failed one in ADR-0011 --- ...11-subscription-readiness-is-recorded-by-the-socket.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md index 9629f69..ed8cd0f 100644 --- a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md +++ b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md @@ -24,7 +24,7 @@ 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 an errored `nosub`, and per +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 @@ -88,6 +88,6 @@ Readiness is recorded state; the query is derived; the query sends nothing. 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 — an errored one forgets the - entry, an errorless one keeps it, and the SDK never inspects `msg` to tell - them apart — are unchanged here and tracked as #360. +- 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. From 596727ec8ff69ce42b3bc674b6a00e314a653726 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 13:54:05 -0300 Subject: [PATCH 15/21] refactor: match confirmed subs through findSubscriptions --- lib/drivers/socket.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index 2b8c5e2..de9d071 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -804,18 +804,13 @@ export class Socket extends SDKEventEmitter { params.every((param, index) => sub.params?.[index] === param) )) - private hasConfirmedSubscription = ({ name, params = [] }: IStream): boolean => { - for (const id of this.confirmedSubscriptionIds) { - const sub = this.subscriptions[id] - if ( - sub && - sub.name === name && + private hasConfirmedSubscription = ({ name, params = [] }: IStream): boolean => + this.findSubscriptions({ name, params }).some( + (sub) => sub.params?.length === params.length && - params.every((param, index) => sub.params?.[index] === param) - ) return true - } - return false - } + sub.id !== undefined && + this.confirmedSubscriptionIds.has(sub.id) + ) whenReady = ( streams: IStream[], From 16ba7cb0610ec649166e0d85d377783dc6726685 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 13:54:05 -0300 Subject: [PATCH 16/21] test: name the resolution tracker once in a shared helper --- lib/drivers/__tests__/driver.spec.ts | 16 +++++++--------- test/trackResolution.ts | 5 +++++ 2 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 test/trackResolution.ts diff --git a/lib/drivers/__tests__/driver.spec.ts b/lib/drivers/__tests__/driver.spec.ts index e6cd750..ae6c871 100644 --- a/lib/drivers/__tests__/driver.spec.ts +++ b/lib/drivers/__tests__/driver.spec.ts @@ -1,6 +1,7 @@ import { Driver } from '../driver' import { ISocketOptions } from '../../../interfaces' import { createSilentLogger } from '../../../test/createSilentLogger' +import { trackResolution } from '../../../test/trackResolution' import { CLOSED, driveToHandshake, @@ -132,11 +133,10 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { await addMediaSubscription(driver, transport, 'media-signal') const waiting = driver.waitForNotifyUserMediaSubs() - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + const resolution = trackResolution(waiting) await jest.advanceTimersByTimeAsync(1) - expect(resolved).toBeUndefined() + expect(resolution.value).toBeUndefined() await addMediaSubscription(driver, transport, 'media-calls') await expect(waiting).resolves.toBe(true) @@ -163,12 +163,11 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { driver.userId = userId const waiting = driver.waitForNotifyUserMediaSubs() - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + const resolution = trackResolution(waiting) driver['socket'].subscribe(topic, [`${userId}/media-signal`], undefined, 'sub-media-signal') await jest.advanceTimersByTimeAsync(1) - expect(resolved).toBeUndefined() + expect(resolution.value).toBeUndefined() transport.receive({ msg: 'ready', subs: ['sub-media-signal'] }) await jest.advanceTimersByTimeAsync(1) @@ -306,11 +305,10 @@ describe('Driver.waitForNotifyUserMediaSubs', () => { driver.userId = userId const waiting = driver.waitForNotifyUserMediaSubs() - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + const resolution = trackResolution(waiting) await jest.advanceTimersByTimeAsync(timeout - 1) - expect(resolved).toBeUndefined() + expect(resolution.value).toBeUndefined() await jest.advanceTimersByTimeAsync(1) await expect(waiting).resolves.toBe(false) diff --git a/test/trackResolution.ts b/test/trackResolution.ts new file mode 100644 index 0000000..0a741dd --- /dev/null +++ b/test/trackResolution.ts @@ -0,0 +1,5 @@ +export const trackResolution = (promise: Promise): { value: T | undefined } => { + const tracker: { value: T | undefined } = { value: undefined } + promise.then((value) => { tracker.value = value }) + return tracker +} From 299c30df5b95b42bc3873ac0ac20fea587a1a149 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 13:54:05 -0300 Subject: [PATCH 17/21] test: pin that asking whenReady for nothing resolves true --- .../__tests__/socket.subscriptions.spec.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/drivers/__tests__/socket.subscriptions.spec.ts b/lib/drivers/__tests__/socket.subscriptions.spec.ts index ad9d3f1..b130c1c 100644 --- a/lib/drivers/__tests__/socket.subscriptions.spec.ts +++ b/lib/drivers/__tests__/socket.subscriptions.spec.ts @@ -1,5 +1,6 @@ import { Socket } from '../socket' import { createSilentLogger } from '../../../test/createSilentLogger' +import { trackResolution } from '../../../test/trackResolution' import { CLOSED, FakeWebSocket, @@ -155,15 +156,18 @@ describe('Socket subscription bookkeeping', () => { { name: 'stream-notify-user', params: ['uid/media-calls'] } ] + it('resolves true when nothing is asked for', async () => { + await expect(socket.whenReady([])).resolves.toBe(true) + }) + it('resolves true once the streams are confirmed on the current connection', async () => { await subscribe('stream-notify-user', ['uid/media-signal']) const waiting = socket.whenReady(streams) - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + const resolution = trackResolution(waiting) await jest.advanceTimersByTimeAsync(1) - expect(resolved).toBeUndefined() + expect(resolution.value).toBeUndefined() await subscribe('stream-notify-user', ['uid/media-calls']) await expect(waiting).resolves.toBe(true) @@ -171,12 +175,11 @@ describe('Socket subscription bookkeeping', () => { it('waits when no entry exists yet', async () => { const waiting = socket.whenReady(streams) - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + const resolution = trackResolution(waiting) socket.subscribe('stream-notify-user', ['uid/media-signal']) await jest.advanceTimersByTimeAsync(1) - expect(resolved).toBeUndefined() + expect(resolution.value).toBeUndefined() const { id: signalId } = transport.lastSent() transport.receive({ msg: 'ready', subs: [signalId] }) @@ -203,11 +206,10 @@ describe('Socket subscription bookkeeping', () => { it('takes its deadline from the configured timeout when given none', async () => { const waiting = socket.whenReady(streams) - let resolved: boolean | undefined - waiting.then((value) => { resolved = value }) + const resolution = trackResolution(waiting) await jest.advanceTimersByTimeAsync(socket.config.timeout - 1) - expect(resolved).toBeUndefined() + expect(resolution.value).toBeUndefined() await jest.advanceTimersByTimeAsync(1) await expect(waiting).resolves.toBe(false) From 8db1fe54ae97c303b83352124d83f97ca30357fc Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:05:15 -0300 Subject: [PATCH 18/21] refactor: record confirmation on the subscription entry One fact in one place: forgetting the entry forgets the confirmation, so forgetSubscription no longer keeps a shadow set in step. A waiter hears about a confirmation through the Socket's own emitter instead of a second notification mechanism beside it. --- lib/drivers/socket.ts | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/lib/drivers/socket.ts b/lib/drivers/socket.ts index de9d071..001d01b 100644 --- a/lib/drivers/socket.ts +++ b/lib/drivers/socket.ts @@ -43,6 +43,8 @@ const socketClosed = 3; const socketDeadlineMs = 2000; +const subscriptionConfirmed = 'subscriptionConfirmed' + const abandonedByReopen = '[ddp] connection reopened before the response arrived' const abandonedByClose = '[ddp] connection closed before the response arrived' const abandonedBySocketChange = '[ddp] connection replaced before the message was written' @@ -93,8 +95,6 @@ export class Socket extends SDKEventEmitter { private settleReopen?: () => void private pendingOpenRejects = new WeakMap void>() private subscriptionRequests: { [id: string]: Promise } = {} - private confirmedSubscriptionIds = new Set() - private readinessListeners = new Set<() => void>() /** Create a websocket handler */ constructor ( @@ -139,7 +139,7 @@ export class Socket extends SDKEventEmitter { this.logger.error(err) return reject(err) } - this.confirmedSubscriptionIds.clear() + this.forgetConfirmations() // Tear down the previous connection before replacing it. // Callers only reach here when the existing socket isn't healthy, so // detaching its handlers and closing it stops a stale or still-connecting @@ -209,7 +209,7 @@ export class Socket extends SDKEventEmitter { if (closedConnection && closedConnection !== this.connection) { return } - this.confirmedSubscriptionIds.clear() + this.forgetConfirmations() this.emit('close', e) try { if (e?.code !== userDisconnectCloseCode) { @@ -350,7 +350,6 @@ export class Socket extends SDKEventEmitter { /** Drop one DDP subscription. */ forgetSubscription = (id: string) => { - this.confirmedSubscriptionIds.delete(id) delete this.subscriptions[id] } @@ -748,9 +747,9 @@ export class Socket extends SDKEventEmitter { .then((result) => { const confirmedId = result.subs?.[0] if (!confirmedId) return undefined - const subscription = this.recordSubscription(confirmedId, name, params, callback) - if (subscription) this.confirmSubscription(confirmedId) - return subscription + return this.confirmSubscription( + this.recordSubscription(confirmedId, name, params, callback) + ) }) .catch((err) => { this.logger.error(`[ddp] Subscribe error: ${err.message}`) @@ -785,11 +784,18 @@ export class Socket extends SDKEventEmitter { return subscription } - private confirmSubscription = (id: string) => { - this.confirmedSubscriptionIds.add(id) - this.readinessListeners.forEach((listener) => listener()) + private confirmSubscription = (subscription?: ISubscription) => { + if (!subscription) return undefined + subscription.confirmed = true + this.emit(subscriptionConfirmed) + return subscription } + private forgetConfirmations = () => + Object.keys(this.subscriptions).forEach((id) => { + this.subscriptions[id].confirmed = false + }) + /** * The DDP subscriptions on this Socket for one stream name, matched on the * params given. `subscriptions` is keyed by DDP subscription id, so a caller @@ -806,30 +812,25 @@ export class Socket extends SDKEventEmitter { private hasConfirmedSubscription = ({ name, params = [] }: IStream): boolean => this.findSubscriptions({ name, params }).some( - (sub) => - sub.params?.length === params.length && - sub.id !== undefined && - this.confirmedSubscriptionIds.has(sub.id) + (sub) => sub.confirmed && sub.params?.length === params.length ) whenReady = ( streams: IStream[], timeoutMs = this.config.timeout ): Promise => { - if (streams.every(this.hasConfirmedSubscription)) return Promise.resolve(true) return new Promise((resolve) => { const finish = (value: boolean) => { clearTimeout(deadline) - this.readinessListeners.delete(resolveIfConfirmed) + this.off(subscriptionConfirmed, resolveIfConfirmed) resolve(value) } const resolveIfConfirmed = () => { - if (streams.every(this.hasConfirmedSubscription)) { - finish(true) - } + if (streams.every(this.hasConfirmedSubscription)) finish(true) } const deadline = setTimeout(() => finish(false), timeoutMs) - this.readinessListeners.add(resolveIfConfirmed) + this.on(subscriptionConfirmed, resolveIfConfirmed) + resolveIfConfirmed() }) } From 847ad9eb001198a82d198058cbec414315d86eb2 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:05:15 -0300 Subject: [PATCH 19/21] test: read the newest fake socket through the shared helper --- lib/drivers/__tests__/socket.connection.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/drivers/__tests__/socket.connection.spec.ts b/lib/drivers/__tests__/socket.connection.spec.ts index 443e713..85e0a94 100644 --- a/lib/drivers/__tests__/socket.connection.spec.ts +++ b/lib/drivers/__tests__/socket.connection.spec.ts @@ -7,6 +7,7 @@ import { driveToHandshake, FakeWebSocket, fakeSockets, + mostRecentFakeSocket, fakeTransportModule, OPEN, openFakeConnection, @@ -378,7 +379,7 @@ describe('Socket connection lifecycle', () => { await jest.advanceTimersByTimeAsync(CLOSE_DEADLINE - 1) const reopening = socket.reopenNow() - const replacement = fakeSockets[fakeSockets.length - 1] + const replacement = mostRecentFakeSocket() await driveToHandshake(replacement) await reopening From 1656e98e3147f0ebe216005c0437ff1e4ffedbb4 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:05:15 -0300 Subject: [PATCH 20/21] docs: describe readiness as a field on the entry in ADR-0011 --- ...d-by-a-forced-reconnect-keeps-its-entry.md | 2 +- ...ion-readiness-is-recorded-by-the-socket.md | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md b/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md index 9edb1c9..a6a3239 100644 --- a/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md +++ b/docs/adr/0006-a-sub-abandoned-by-a-forced-reconnect-keeps-its-entry.md @@ -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 diff --git a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md index ed8cd0f..3a8eb3e 100644 --- a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md +++ b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md @@ -43,16 +43,17 @@ and the reason has to be re-established first. It is tracked as #359. Readiness is recorded state; the query is derived; the query sends nothing. -- Readiness lives in a private set of confirmed subscription ids the Socket - owns, beside the entries; the entry itself stays a stream handle and the - public `ISubscription` carries no readiness field. `whenReady` is a thin - query over the entries and the set: 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` clears the set the moment - the connection ends and `createConnection` clears it again for the new - one, so a reopen turns every sub Unconfirmed without a pass over - `subscriptions` — the clear does the forgetting. +- 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 From 72f8ef8c798b3ef042fd1ff223c62d9c1af3246e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:09:19 -0300 Subject: [PATCH 21/21] refactor: declare confirmed on ISubscription The field the readiness path reads was only compiling through the index signature, so a typo passed silently and consumers compiling this source saw nothing of the domain term. ADR-0011 now describes how the readiness path actually reads findSubscriptions. --- .../0011-subscription-readiness-is-recorded-by-the-socket.md | 4 ++-- interfaces/index.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md index 3a8eb3e..97d7379 100644 --- a/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md +++ b/docs/adr/0011-subscription-readiness-is-recorded-by-the-socket.md @@ -65,8 +65,8 @@ Readiness is recorded state; the query is derived; the query sends nothing. 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 `===`. - The prefix match in `findSubscriptions` stays as it is for its current - callers and is kept out of the readiness path. + `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`. diff --git a/interfaces/index.ts b/interfaces/index.ts index 64d7310..3487b4c 100644 --- a/interfaces/index.ts +++ b/interfaces/index.ts @@ -173,6 +173,7 @@ export interface ICallback { export interface ISubscription { id?: string name?: any + confirmed?: boolean unsubscribe: () => Promise onEvent?: (callback: ISocketMessageCallback) => void [key: string]: any