Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ddp-dispatcher-non-method-frames.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions packages/ddp-client/__tests__/DDPDispatcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
20 changes: 17 additions & 3 deletions packages/ddp-client/src/DDPDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { MinimalDDPClient } from './MinimalDDPClient';
import type { OutgoingPayload } from './types/OutgoingPayload';
import type { MethodPayload } from './types/methodsPayloads';

type Blocks = {
Expand All @@ -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) {
Expand Down
Loading