#14746·meteor

uws transport: the DDP 'failed' frame of version negotiation is never delivered

Author: dupontbertrandCreated Sep 12, 2026Updated Sep 12, 2026

Problem hit while running the DDP package suites under the uws transport in development; issue written and opened by Claude Code after review by the author.

Summary

With DDP_TRANSPORT=uws, DDP version negotiation never completes: the server's failed message is not delivered, so the client never learns which version to use. The same exchange works on sockjs.

livedata_server.js sends that message and closes the socket in the same tick, at two places:

// livedata_server.js:1500-1501, when a better version than the proposed one exists
socket.send(DDPCommon.stringifyDDP({msg: 'failed', version: version}));
socket.close();

The same pair sits at :1486-1488, for a connect message that does not list the version it proposes; it sends SUPPORTED_DDP_VERSIONS[0] instead of version.

uWebSockets.js documents WebSocket.close() as "Forcefully closes this WebSocket. Immediately calls the close handler. No WebSocket close message is sent", against end(code?, shortMessage?): "Gracefully closes this WebSocket. Immediately calls the close handler. A WebSocket close message is sent with code and shortMessage" ([email protected], index.d.ts:70-78). In practice the forceful variant also drops the frame queued just before it.

packages/ddp-server/transports/uws.js adapts the native socket to the shape the rest of ddp-server expects: it installs socket.on (line 47), socket.setWebsocketTimeout (line 60), socket.protocol and socket.headers (lines 64-65). It does not adapt close(), so the call above reaches uWS's forceful close and the failed frame is lost. On sockjs, SockJSConnection.close() delegates to Session.close(status, reason), which defaults to code 1000 "Normal closure" and delivers what was queued, which is the behaviour the call sites were written against.

Reproduction 1: uWS alone, no Meteor

Send one frame, then close, on the same socket in the same tick:

import uws from 'uWebSockets.js';
const app = uws.App();
app.ws('/*', {
  open(socket) {
    socket.send(JSON.stringify({ msg: 'failed', version: '1' }));
    if (process.env.MODE === 'end') socket.end(); else socket.close();
  },
});
app.listen('127.0.0.1', 5199, uws.LIBUS_LISTEN_EXCLUSIVE_PORT, (token) => {
  // EXCLUSIVE_PORT makes a second listen fail silently, so check the token:
  // otherwise a leftover server on 5199 answers and the result is misleading.
  if (!token) throw new Error('could not listen on 127.0.0.1:5199');
});

A ws client connecting to it observes:

server call frames received payload close code
socket.close() 0 none 1006
socket.end() 1 {"msg":"failed","version":"1"} 1005

Reproduction 2: Meteor, sockjs against uws

Start any app twice from the same checkout, once with the default transport and once with DDP_TRANSPORT=uws, then send a connect over a raw WebSocket and wait for the reply:

// node repro.mjs ws://localhost:3000/websocket
import WebSocket from 'ws';
const ws = new WebSocket(process.argv[2]);
ws.on('open', () => ws.send(JSON.stringify({ msg: 'connect', version: 'garbled', support: ['garbled', '1'] })));
ws.on('message', (d) => console.log('received:', d.toString()));
setTimeout(() => { console.log('nothing received'); process.exit(1); }, 5000);
connect sent sockjs uws
version: '1', support: ['1'] connected connected
version: 'garbled', support: ['garbled', '1'] failed, version: "1" nothing, times out
version: 'garbled', support: ['garbled'] failed, version: "1" nothing, times out

The first row succeeds because no close follows the reply. Only the two failure paths lose their frame.

Impact

_handleFailedMessage (packages/ddp-client/common/connection_stream_handlers.js:108) is the only place where the client adopts the version the server asks for, or gives up through onDDPVersionNegotiationFailure. With the frame dropped, neither branch runs. The server still closes the socket, so the client reconnects, and _buildConnectMessage (:155-156) proposes the same version again from the unchanged _versionSuggestion, so the exchange repeats.

In the test suites, ./packages/test-in-console/run.sh "ddp-client" with DDP_TRANSPORT=uws fails the two version negotiation tests and then hangs without printing a total:

S: tinytest - livedata connection - version negotiation requires renegotiating : !!!!!!!!! FAIL !!!!!!!!!!!
S: tinytest - livedata connection - version negotiation error : !!!!!!!!! FAIL !!!!!!!!!!!

Both are in packages/ddp-client/test/livedata_connection_tests.js:1973 and :1991, and both pass on sockjs. The test-ddp-transport workflow runs the matrix over ddp-server only, so the uws leg never reaches the ddp-client package where these live.

Suggested fix

Restore the expected close() contract inside the transport, next to the adapters already installed in open(socket):

socket.close = function () {
  socket.end();
};

Keeping it in the transport rather than at the call sites matters: livedata_server closes sockets in three places (:326, :1488, :1501) and must stay transport-agnostic, which is the point of the pluggable transport work in #14231.

With that change, reproduction 2 behaves as on sockjs and ddp-client completes under uws with 153 passes and 0 failures. Before it: 2 failures and no total.

While fixing this, the socket contract a transport must provide (on('data'|'close'), send(), close() after flushing, protocol, headers, setWebsocketTimeout) is worth writing down next to the transport interface in packages/ddp-server/transports/index.js; nothing states it today, which is how the gap went unnoticed.

Question for uWebSockets.js

@uNetworkingAB, thanks again for the guidance on the version bump in #14330. One question on the behaviour above, since the documentation does not cover it: close() is documented as not sending a close message, and it also drops a frame handed to send() in the same tick. Reproduction 1 measures 0 frames delivered and close code 1006 with close(), against 1 frame and code 1005 with end().

Is that intended, that is, is end() the only supported way to deliver a last frame before closing? If it is, a line in the close() documentation would save the next person this hunt. If it is not, we are glad to open an issue on your side with the reproduction above.

Environment

  • Meteor release-3.6 at 7bd27aafe3 (3.6-beta.0), [email protected], Node 24.15.0, Linux x86_64
  • DDP_TRANSPORT=uws, default uws settings (port 5001, host 127.0.0.1)