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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions