diff --git a/.changeset/ddp-dispatcher-non-method-frames.md b/.changeset/ddp-dispatcher-non-method-frames.md new file mode 100644 index 0000000000000..c54d34e3b5164 --- /dev/null +++ b/.changeset/ddp-dispatcher-non-method-frames.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ddp-client": patch +--- + +Fix `DDPDispatcher` dropping non-method frames (connect, sub, unsub, ping, pong) when a `wait` block is at the head of the queue. Previously every payload flowed through the same wait-serialization path: a `connect` frame dispatched after a `wait: true` method (e.g. `login`) would be queued in a new non-wait block but never actually sent, wedging the DDP handshake — the socket stayed open, the server never replied `connected`, and any caller awaiting the connection hung. Non-method payloads now bypass the queue and emit immediately; wait-method serialization between methods is unchanged. diff --git a/packages/ddp-client/__tests__/DDPDispatcher.spec.ts b/packages/ddp-client/__tests__/DDPDispatcher.spec.ts index 7dfde76260ae4..ef61bedd654b6 100644 --- a/packages/ddp-client/__tests__/DDPDispatcher.spec.ts +++ b/packages/ddp-client/__tests__/DDPDispatcher.spec.ts @@ -72,6 +72,34 @@ it('should send outstanding blocks if there is no block waiting and item is adde expect(fn).toHaveBeenCalledTimes(1); }); +it('emits non-method payloads immediately, even when a wait block is at the head', () => { + // Regression: a connect frame dispatched while a wait `login` method is + // queued must still reach the server. Otherwise the DDP handshake never + // completes and the socket wedges open but unconnected. + const fn = jest.fn(); + const ddpDispatcher = new DDPDispatcher(); + ddpDispatcher.on('send', fn); + + const login = ddp.call('login'); + ddpDispatcher.dispatch(login, { wait: true }); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenNthCalledWith(1, login); + + const connectPayload = { msg: 'connect' as const, version: '1', support: ['1'] }; + ddpDispatcher.dispatch(connectPayload); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(2, connectPayload); + + const subPayload = { msg: 'sub' as const, id: 'a', name: 'foo', params: [] }; + ddpDispatcher.dispatch(subPayload); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenNthCalledWith(3, subPayload); + + // Wait block remains pending — only the wait method is queued, the + // non-method frames bypassed it. + expect(ddpDispatcher.queue).toEqual([{ wait: true, items: [login] }]); +}); + it('should send the next blocks if the outstanding block was completed', () => { const fn = jest.fn(); diff --git a/packages/ddp-client/src/DDPDispatcher.ts b/packages/ddp-client/src/DDPDispatcher.ts index b24d1d12a0be9..c2c61f33df201 100644 --- a/packages/ddp-client/src/DDPDispatcher.ts +++ b/packages/ddp-client/src/DDPDispatcher.ts @@ -3,6 +3,7 @@ */ import { MinimalDDPClient } from './MinimalDDPClient'; +import type { OutgoingPayload } from './types/OutgoingPayload'; import type { MethodPayload } from './types/methodsPayloads'; type Blocks = { @@ -15,12 +16,25 @@ type Queue = Blocks[]; export class DDPDispatcher extends MinimalDDPClient { queue: Queue = []; - override dispatch(msg: MethodPayload, options?: { wait?: boolean }) { + override dispatch(payload: OutgoingPayload, options?: { wait?: boolean }) { + // Only method payloads participate in the wait/queue serialization that + // implements Meteor's wait-method semantics. Protocol-level frames + // (connect, sub, unsub, ping, pong) must be delivered immediately: + // queueing a `connect` frame behind a wait `login` block, for example, + // wedges the DDP handshake — the socket opens but `connect` never + // reaches the server, so the server never replies `connected` and the + // session never establishes. + if (payload.msg !== 'method') { + this.emit('send', payload); + return; + } + if (options?.wait) { - this.wait(msg); + this.wait(payload); return; } - this.pushItem(msg); + + this.pushItem(payload); } wait(block: MethodPayload) {