Skip to content

cleanupEmptyChildNamespaces releases a child namespace while a socket is in its middleware, and the cleanup unregisters a newer namespace by name #5587

Open

Description

@perki

Describe the bug

With cleanupEmptyChildNamespaces: true, a child namespace is released as soon as namespace.sockets.size === 0, even while another socket of that namespace is still in its (async) middleware. That socket then joins the released namespace object, while the next connection gets a new object under the same name. Two consequences:

  1. Broadcasting with io.of(name).emit(...) reaches only the sockets of the newer object; the socket admitted into the released object receives nothing.
  2. When the released object later empties, the cleanup deletes the _nsps entry by name, which unregisters the newer object while its sockets are still connected; io.of(name) then creates yet another object, and broadcasts no longer reach any connected socket.

(Related but different: #4772, cleanup after a socket is rejected in middleware.)

To Reproduce

Socket.IO server version: 4.8.3

const http = require('http');
const { Server } = require('socket.io');
const { io } = require('socket.io-client');

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const once = (emitter, event) => new Promise((resolve) => emitter.once(event, resolve));

const httpServer = http.createServer();
const ioServer = new Server(httpServer, { cleanupEmptyChildNamespaces: true });

let releaseHandshake;
const handshakeGate = new Promise((resolve) => { releaseHandshake = resolve; });
let handshakes = 0;
ioServer.of(/^\/room-\w+$/).use(async (socket, next) => {
  handshakes++;
  if (handshakes === 2) await handshakeGate; // e.g. an auth lookup that takes a while
  next();
});

httpServer.listen(0, async () => {
  const url = `http://localhost:${httpServer.address().port}/room-a`;
  const opts = { forceNew: true, transports: ['websocket'] };

  const first = io(url, opts);
  await once(first, 'connect');
  const nspAtStart = ioServer._nsps.get('/room-a');

  const held = io(url, opts); // its handshake waits in the middleware
  await sleep(100);
  first.disconnect(); // the only connected socket leaves: the namespace is released
  await sleep(100);
  console.log('1. namespace released while "held" is still in its middleware:',
    ioServer._nsps.get('/room-a') !== nspAtStart);

  releaseHandshake();
  await once(held, 'connect'); // "held" joins the released namespace object
  const third = io(url, opts);
  await once(third, 'connect'); // a new namespace object is created under the same name

  const received = { held: 0, third: 0 };
  held.on('news', () => received.held++);
  third.on('news', () => received.third++);
  ioServer.of('/room-a').emit('news');
  await sleep(200);
  console.log('2. broadcast to of("/room-a") with two connected sockets:', received,
    '(expected { held: 1, third: 1 })');

  held.disconnect(); // the released object empties: cleanup deletes the _nsps entry by name
  await sleep(200);
  console.log('3. after "held" leaves, the namespace "third" is in is still registered:',
    ioServer._nsps.get('/room-a') != null);
  received.third = 0;
  ioServer.of('/room-a').emit('news');
  await sleep(200);
  console.log('4. broadcast to of("/room-a") with "third" still connected:', { third: received.third },
    '(expected { third: 1 })');

  third.disconnect();
  ioServer.close();
  process.exit(0);
});

Output (socket.io 4.8.3, socket.io-client 4.8.3, Node.js 24):

1. namespace released while "held" is still in its middleware: true
2. broadcast to of("/room-a") with two connected sockets: { held: 0, third: 1 } (expected { held: 1, third: 1 })
3. after "held" leaves, the namespace "third" is in is still registered: false
4. broadcast to of("/room-a") with "third" still connected: { third: 0 } (expected { third: 1 })

Expected behavior

  • A namespace with a socket still in its middleware (_preConnectSockets) is not released, or a socket finishing its handshake joins the namespace object currently registered under that name.
  • The cleanup removes the _nsps entry only if it still points to the object being released (if (this._nsps.get(name) === namespace) this._nsps.delete(name)).

Platform:

  • Server: Node.js 24, macOS and Linux

Additional context

The release check is in ParentNamespace.createChild (dist/parent-namespace.js, the cleanupEmptyChildNamespaces handler on namespace.sockets.size === 0), which does not consider namespace._preConnectSockets. We work around it on our side by keeping per-namespace-object state instead of per-name state.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions