You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

刷新浏览器时Socket.io触发栈溢出致Node.js服务崩溃

问题:Node.js+Socket.io刷新浏览器导致服务器栈溢出崩溃

初始WebSocket连接正常,但刷新浏览器时服务器立即崩溃,抛出「RangeError: Maximum call stack size exceeded」错误。已将问题范围缩小到断开连接的逻辑部分,移除io.emit('updateUserList', this.connectedUsers)语句后崩溃现象停止。期望刷新浏览器时,客户端能正常断开并重连,而非导致服务器崩溃。

相关代码

server.js

export const io = new Server(4006, {
    cors: {
        origin: "http://192.168.0.19:5173",
        credentials: true
    }
})

const getSocketByUserId = (userId) => {
    let socket = '';
    for (let i = 0; i < clientSocketIds.length; i++) {
        if (clientSocketIds[i].userId == userId) {
            socket = clientSocketIds[i].socket;
            break;
        }
    }
    return socket;
}
io.use(async (socket, next) => {
    console.log(socket.handshake.auth.userId)
    if(socket.handshake.auth.userId) {
        messageController.socketUserAuthentication(socket, next)
    } else {
        console.log('Already connected')
    }
    console.log('Authenticated')
}).on('connection', socket => {
    console.log('conected')
    messageController.storeConnectedUser(socket)
    socket.on('disconnect', () => {
        messageController.handleUserDisconnect(socket)
    });
    socket.on('create', function (data) {
        console.log("create room")
        socket.join(data.room);
        let withSocket = getSocketByUserId(data.withUserId);
        socket.broadcast.to(withSocket.id).emit("invite", { room: data })
    });
    socket.on('joinRoom', function (data) {
        socket.join(data.room.room);
    });

    socket.on('message', function (data) {
        socket.broadcast.to(data.room).emit('message', data);
    })
});

message.controller.js

import userModel from '../user/user.model.js'
import emailModel from '../emails/email.model.js'
import calendarModel from '../calendar/calendar.model.js'
import { HttpException } from '../../utility/HttpException.utility.js'
import { validationResult } from 'express-validator'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import * as dotenv from 'dotenv'
import { logger } from '../../utility/logger.js'
import { isEmpty } from '../../utility/common.utility.js'
import { addHours, parseISO } from 'date-fns'
import _ from 'lodash'
import { io } from '../../server.js'
import { v4 as uuidv4 } from 'uuid'
import { user_type } from '../../utility/userRoles.utility.js'

dotenv.config()


class messageController {
    constructor() {
        this.connectedUsers = []
        this.clientSocketIds = []

    }
    test = () => {
        console.log('CLIENT SOCKET IDS',this.clientSocketIds)
        console.log('CONNECTED USERS',this.connectedUsers)
    }
    getSocketByUserId = (userId) => {
        let socket = ''
        for (let i = 0; i < this.clientSocketIds.length; i++) {
            if (clientSocketIds[i].userId == userId) {
                socket = clientSocketIds[i].socket
                break
            }
        }
        return socket
    }
    socketUserAuthentication = async (socket, next) => {
        try {
            this.test()
            console.log('Authenticate')
            const authHeader = socket.handshake.auth.token
            const id = socket.handshake.auth.userId
            console.log(authHeader)
            console.log(id)

            const bearer = 'Bearer '

            if (!authHeader) {
                console.log(authHeader)
                return new HttpException(401, 'Access denied. No credentials sent!')
            }

            const token = authHeader.replace(bearer, '')
            const secretKey = process.env.SECRET_JWT || ""

            const decoded = jwt.verify(token, secretKey)
            const user = await userModel.findUser({ userId: decoded.user_id })

            if (!user) {
                return new HttpException(401, 'Authentication failed!')
            }
            const ownerAuthorized = id == user.userId
            if (!ownerAuthorized && roles.length && !roles.includes(user.user_type)) {
                return new HttpException(401, 'Unauthorized')
            }
            socket.currentUser = user;
            return next();
        } catch (e) {
            e.status = 401;
            console.log(e)
            next(e);
        }
    }

    storeConnectedUser = (socket) => {
        this.clientSocketIds.push({ socket: socket, userId: socket.currentUser.userId, username: socket.currentUser.username })
        this.connectedUsers = this.connectedUsers.filter(item => item.userId != socket.currentUser.userId)
        this.connectedUsers.push({ username: socket.currentUser.username, userId: socket.currentUser.userId, uuid: uuidv4(), socket: socket })
        return socket
    }

    handleUserDisconnect = (socket) => {
        this.connectedUsers = this.connectedUsers.filter(item => item.socketId != socket.id)
        io.emit('updateUserList', this.connectedUsers)
        return socket
    }
}

export default new messageController

解决方案

问题根源

  1. 循环引用导致序列化栈溢出:this.connectedUsers中存储了完整的socket对象,该对象内部包含大量循环引用(比如socket引用io实例,io又引用所有socket连接、事件监听器等)。当io.emit尝试将这个数组序列化为JSON时,JSON.stringify会陷入无限递归,最终触发栈溢出错误。
  2. 过滤条件错误:handleUserDisconnect中的filter(item => item.socketId != socket.id)是无效的——存储的用户项里并没有socketId字段,只有socket对象。这导致断开的用户无法从数组中移除,无效socket对象越积越多,进一步加重序列化负担。
  3. 作用域错误:getSocketByUserId方法中错误引用了全局的clientSocketIds,而非类实例的this.clientSocketIds,导致无法正确查找用户对应的socket。

修复步骤

1. 修正断开连接逻辑,移除socket对象的序列化

修改handleUserDisconnect方法,正确过滤断开的用户,并仅向前端发送必要的业务字段(不包含socket对象):

handleUserDisconnect = (socket) => {
    // 用socket.id匹配存储项中的socket.id,正确移除断开的用户
    this.connectedUsers = this.connectedUsers.filter(item => item.socket.id !== socket.id)
    // 同时清理clientSocketIds中的无效记录
    this.clientSocketIds = this.clientSocketIds.filter(item => item.socket.id !== socket.id)
    
    // 仅发送前端需要的字段,避免序列化socket对象
    io.emit('updateUserList', this.connectedUsers.map(user => ({
        username: user.username,
        userId: user.userId,
        uuid: user.uuid
    })))
    return socket
}

2. 优化连接存储逻辑,发送用户列表时避免socket序列化

修改storeConnectedUser方法,确保重复连接的用户被正确覆盖,并且发送用户列表时只传必要字段:

storeConnectedUser = (socket) => {
    // 先清理该用户已有的socket记录,避免重复
    this.clientSocketIds = this.clientSocketIds.filter(item => item.userId !== socket.currentUser.userId)
    this.clientSocketIds.push({ socket: socket, userId: socket.currentUser.userId, username: socket.currentUser.username })
    
    this.connectedUsers = this.connectedUsers.filter(item => item.userId !== socket.currentUser.userId)
    this.connectedUsers.push({ 
        username: socket.currentUser.username, 
        userId: socket.currentUser.userId, 
        uuid: uuidv4(), 
        socket: socket 
    })
    
    // 仅发送前端需要的字段
    io.emit('updateUserList', this.connectedUsers.map(user => ({
        username: user.username,
        userId: user.userId,
        uuid: user.uuid
    })))
    return socket
}

3. 修复getSocketByUserId的作用域错误

修改方法内的引用,使用类实例的this.clientSocketIds:

getSocketByUserId = (userId) => {
    let socket = ''
    for (let i = 0; i < this.clientSocketIds.length; i++) {
        // 修正为this.clientSocketIds
        if (this.clientSocketIds[i].userId == userId) {
            socket = this.clientSocketIds[i].socket
            break
        }
    }
    return socket
}

验证效果

修复后,刷新浏览器时:

  • 断开连接逻辑会正确移除用户的socket记录
  • 发送用户列表时仅传递业务字段,避免了循环引用的序列化问题
  • 服务器不会再出现栈溢出崩溃,客户端可正常断开并重连

内容的提问来源于stack exchange,提问作者MantasDev

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.10 13:05:22