Skip to content

hasBinary() throws RangeError: Maximum call stack size exceeded on Mongoose documents, silently dropping events in sharded mode #572

@vishnukumarya-kore

Description

@vishnukumarya-kore

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 event

Expected behaviour

hasBinary() should either:

  1. 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;
}
  1. 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
}
  1. 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

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