-
Notifications
You must be signed in to change notification settings - Fork 493
Description
Description
hasBinary() in dist/util.js performs unbounded recursive traversal of the packet payload to decide whether to encode with msgpack or JSON.
When the payload contains a Mongoose document, the traversal walks into Mongoose's internal $__ property (InternalCache), which holds references to the schema, model, validators, and other objects that contain circular references. This causes:
RangeError: Maximum call stack size exceeded
The toJSON guard (lines 24–26) that would short-circuit the traversal is placed after the for...in loop, so it is never reached for Mongoose documents because the stack overflows first.
Root cause in util.js
// util.js - hasBinary()
function hasBinary(obj, toJSON) {
if (!obj || typeof obj !== "object") return false;
if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) return true;
if (Array.isArray(obj)) { /* ... */ }
// ❌ Recurses into ALL own keys first — hits $__ → circular ref → stack overflow
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key) && hasBinary(obj[key])) {
return true;
}
}
// ❌ This toJSON short-circuit is never reached for Mongoose docs
if (obj.toJSON && typeof obj.toJSON === "function" && !toJSON) {
return hasBinary(obj.toJSON(), true);
}
return false;
}Downstream effect
The exception propagates up through encode() → doPublish() → publishAndReturnOffset(), which rejects the promise in broadcast(). Due to the related silent-drop bug in ClusterAdapter.broadcast() (see socketio/socket.io-adapter#XXX), the event is never delivered to any socket, with no error surfaced unless DEBUG=socket.io-redis is enabled.
Steps to reproduce
const mongoose = require('mongoose');
const MyModel = mongoose.model('MyModel', new mongoose.Schema({ name: String }));
const doc = new MyModel({ name: 'test' }); // ← Mongoose document
// With sharded adapter active:
io.to(roomId).emit('my-event', doc);
// → RangeError: Maximum call stack size exceeded (silently caught)
// → No socket receives the eventExpected behaviour
hasBinary() should either:
- Check toJSON first — if the object exposes .toJSON(), call it and check the plain result instead of recursing into internal properties:
function hasBinary(obj, toJSON) {
if (!obj || typeof obj !== "object") return false;
if (obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) return true;
// Check toJSON BEFORE recursing into raw properties
if (!toJSON && obj.toJSON && typeof obj.toJSON === "function") {
return hasBinary(obj.toJSON(), true);
}
if (Array.isArray(obj)) { /* ... */ }
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key) && hasBinary(obj[key])) {
return true;
}
}
return false;
}- Or use a WeakSet to detect cycles and bail out safely:
function hasBinary(obj, toJSON, visited = new WeakSet()) {
if (!obj || typeof obj !== "object") return false;
if (visited.has(obj)) return false; // cycle guard
visited.add(obj);
// ... rest of checks passing visited along
}- Or only recurse into plain objects (skip class instances entirely unless they are Array/ArrayBuffer):
const proto = Object.getPrototypeOf(obj);
if (proto !== Object.prototype && proto !== null) {
// Non-plain object: check toJSON only, don't recurse into internals
if (!toJSON && obj.toJSON && typeof obj.toJSON === "function") {
return hasBinary(obj.toJSON(), true);
}
return false;
}Workaround
Convert Mongoose documents to plain objects before emitting:
io.to(roomId).emit('my-event', doc.toObject());Environment
- @socket.io/redis-adapter: 8.3.x
- socket.io-adapter: 2.x
- Redis: 7.x cluster (sharded pub/sub mode via createShardedAdapter)
- ioredis: 5.9.x (Cluster)
- mongoose: 8.x
- Node.js: 18.x