Websockets broken on Chrome

Am I missing something? It seems that Chrome is not working correctly with websockets.

Here is what is going on:

On one page, I connect with a websocket. I get my websocket ID back, then I start a “heartbeat” where I send a message _socket.send() every 5 seconds. The socket ID I get matches the one I was given on the connection.

Now I move to another page which opens a websocket. I get back a new socket ID, but when the “heartbeat” starts, I get the old socket ID back. However, at some random interval (could be a few seconds, could be a few minutes) I start getting the new socket ID back.

I spent the past 2 days re writing my user identifier thinking I had a bug or was going mad when I realized this only happens in Chrome and not in Firefox, so I am not sure what is going on.

Lucee: 7.1.0.204
Websocket: 3.0.0.20-SNAPSHOT

Thanks for the detailed writeup — I’ve logged it as LDEV-6457: Jira

Since Firefox returns the correct socket ID immediately and only Chrome returns the stale/previous one for a while, this looks like a connection-reuse/lifecycle issue. A couple of things that would help:

  • A minimal reproducible example (the two pages + the client-side connect/heartbeat code and how you read the socket ID) so we can reproduce it here.
  • Chrome version, and whether the old connection is actually being closed (check chrome://net-export or the Network → WS tab to see if the previous socket lingers).
  • Whether disabling any keep-alive/reconnect logic on the client changes the behavior.

Please follow LDEV-6457 for updates.

I just updated Chrome to the newest version since there was one to do so. So the version I am on atm is: Version 152.0.7977.65 (Official Build) (64-bit)

When the new page load there is no lingering socket. It shows only 1 socket connection going out.

As for the code, it is pretty simple now as I spent some time tearing it apart to figure out what was going wrong. The Websocket.cfc is almost a duplicate of what is posted in the documentation.

Websocket.cfc

component hint="websockets" 
{
    Application.datasourceName = 'mytable';

    Application.setKeyDataPath = "/home/test/domains/dev.test.com/keyData";

    Application.usePBKDF = true;
    Application.password = toBinary( SecretProviderGet("appPassword") );
    Application.salt = toBinary( SecretProviderGet("appSalt") );
    Application.keyRingFilename = toBinary( SecretProviderGet("appKeyRingFilename") );
    Application.keyRingPath = SecretProviderGet("appKeyRingPath") & hash( toString( application.keyRingFilename, 'UTF-8' ), 'MD5', 'UTF-8', 142 ) & '.bin';
    Application.engine = server.coldfusion.productname;
    Application.masterKey = generatePBKDFKey( 'PBKDF2WithHmacSHA1', toString( Application.password, 'UTF-8' ), toString( Application.salt, 'UTF-8' ), 2048, 128 );
    Application.developmentHmacKey = SecretProviderGet("appDevelopmentHmacKey");

    Application.securityService = new cfc.model.services.EncDecService(
            keyRingPath = application.keyRingPath,
            masterKey = application.masterKey
        );

    Application.keyRing = Application.securityService.readKeyRingFromDisk();

    Application.securityService = Application.securityService.init( 
            encryptionKey1          = Application.keyRing[1].key,
            encryptionAlgorithm1    = Application.keyRing[1].alg,
            encryptionEncoding1     = Application.keyRing[1].enc,
            encryptionIV1           = binaryDecode( Application.keyRing[1].iv, 'BASE64' ),
            encryptionKey2          = Application.keyRing[2].key,
            encryptionAlgorithm2    = Application.keyRing[2].alg,
            encryptionEncoding2     = Application.keyRing[2].enc,
            encryptionIV2           = binaryDecode( Application.keyRing[2].iv, 'BASE64' ),
            encryptionKey3          = Application.keyRing[3].key,
            encryptionAlgorithm3    = Application.keyRing[3].alg,
            encryptionEncoding3     = Application.keyRing[3].enc,
            encryptionIV3           = binaryDecode( Application.keyRing[3].iv, 'BASE64' ),
            hmacKey                 = ( ( Application.securityService.getEnvironment() eq 'prod' ) ? generateSecretKey( 'HMACSHA512' ) : application.developmentHmacKey ),
            hmacAlgorithm           = 'HMACSHA512',
            hmacEncoding            = 'UTF-8',
            scN                     = 16384,
            scR                     = 16,
            scP                     = 1
        );

        // clear out temp keys from the application scope
        for( item in [ 'password', 'salt', 'keyRingFilename', 'keyRingPath', 'keyRing',  'masterKey', 'developmentHmacKey', 'usePBKDF' ] ) {
            structDelete( application, item );
        }

    static {
        clientsByUser = {};  // userId -> wsClient
        rolesByUser   = {};  // userId -> array of roles
        dsName = 'mytable'
    }
    
    public static function onFirstOpen( wsclients ) 
    {
        
    }

    function onOpen( wsclient )
    {
        //as of snapshot 3.0.0.20 there is no access to client Session Scope or Server Application Scope.
        //Session in websockets is only the session for that websocket
        //static.wsclients.broadcast("There are now #static.wsclients.size()# connections");
        //onOpen(), we have no way of identifying who just made contact, need to wait for the 1st message with information about user to be sent
        //arguments.wsclient.send( 'incoming' );
        lock name="socketOpen" timeout="30" throwontimeout="no" type="exclusive"
        {

            var userId = getUserIdFromRequest();//really is the socketID

            dt = {}
            dt.type = 0;
            dt.message = '';
            if( userId <= 0)
            {
                dt.message = 'Denied';
                dt.userId = userId;
                arguments.wsClient.send(serializeJSON(dt));
                arguments.wsClient.close();
                return;
            }

            static.clientsByUser[ userId ] = arguments.wsClient;
            //static.rolesByUser[ userId ]   = getRolesForUser( userId );
            arguments.wsClient.send( serializeJSON(wsclient) );
            arguments.wsClient.send( serializeJSON(session) );
            arguments.wsClient.send( serializeJSON(cgi) );
            arguments.wsClient.send( serializeJSON(userId) );
        }
    }

    function onOpenAsync( wsclient )
    {

    }

    function onMessage( wsclient, message )
    {
        if( isJSON(arguments.message))
        {   
            local.msg = deserializeJSON(arguments.message);
            
            //receiving a non init message from client, this should contain no data, just used to keepAlive
            //update users activeCon section
            if( structKeyExists(msg, "heartbeat") )
            {
                local.wsInfo = websocketInfo(false);
                local.wsInstances = wsInfo.instances;
                local.found = 0;

                for ( var wsI in wsInstances ) 
                {
                    if( structKeyExists(wsI.session.requestParameter, 'uuid'))
                    {
                        try 
                        {
                            local.queryService = new query(datasource = "#Application.datasourceName#", maxrows="1");
                            local.sql = 'SELECT ID, userID
                                        FROM websocket
                                        WHERE token = :token AND userID = :userID AND websocketID = :websocketID';
                            queryService.setSQL(sql);
                            
                            queryService.addParam( name='token', value='#wsI.session.requestParameter.uuid[1]#', cfsqltype='cf_sql_longvarchar');
                            queryService.addParam( name='userID', value='#msg.userID#', cfsqltype='cf_sql_bigint');
                            queryService.addParam( name='websocketID', value='#wsI.session.id#', cfsqltype='cf_sql_longvarchar');
                            
                            local.qFind = queryService.execute().getResult();

                            if( qFind.recordcount > 0)
                            {   
                                //found the userID who sent the message
                                local.queryService = new query(datasource = "#Application.datasourceName#");
                                local.sql = 'UPDATE users
                                            SET activeCon = :activeCon
                                            WHERE ID = :ID';
                                queryService.setSQL(sql);
                                
                                queryService.addParam( name='activeCon', value='#Now()#', cfsqltype='cf_sql_datetime');
                                queryService.addParam( name='ID', value='#qFind.userID#', cfsqltype='cf_sql_bigint');                                    
                                local.qUpdate = queryService.execute();

                                //update the websocket time too
                                local.queryService = new query(datasource = "#Application.datasourceName#");
                                local.sql = 'UPDATE websocket
                                            SET lastModified = :webService
                                            WHERE ID = :ID';
                                queryService.setSQL(sql);
                                
                                queryService.addParam( name='webService', value='#Now()#', cfsqltype='cf_sql_datetime');
                                queryService.addParam( name='ID', value='#qFind.ID#', cfsqltype='cf_sql_bigint');
                                
                                local.qUpdate = queryService.execute();
                                local.dt = {};
                                dt.type = 0;
                                dt.heartbeat = 1;
dt.socketID = wsI.session.id;
dt.uuid = wsI.session.requestParameter.uuid[1];
                                arguments.wsclient.send( serializeJSON(dt) );
                                found++;
                                break;
                            }
                        }
                        catch(any e)
                        {
                            //close connection, there is an error
                            arguments.wsclient.send( e.Message );
                            arguments.wsclient.close();
                        }
                    }
                    else if(structKeyExists(wsI.session.requestParameter, 'ses'))
                    {
                        try 
                        {
                            local.queryService = new query(datasource = "#Application.datasourceName#", maxrows="1");
                            local.sql = 'SELECT ID, userID
                                        FROM websocket
                                        WHERE token = :token AND userID = :userID';
                            queryService.setSQL(sql);
                            
                            queryService.addParam( name='token', value='#wsI.session.requestParameter.ses[1]#', cfsqltype='cf_sql_longvarchar');
                            queryService.addParam( name='userID', value='#msg.userID#', cfsqltype='cf_sql_bigint');
                            
                            local.qFind = queryService.execute().getResult();

                            if( qFind.recordcount > 0)
                            {   
                                //found the userID who sent the message
                                local.queryService = new query(datasource = "#Application.datasourceName#");
                                local.sql = 'UPDATE users
                                            SET activeCon = :activeCon
                                            WHERE ID = :ID';
                                queryService.setSQL(sql);
                                
                                queryService.addParam( name='activeCon', value='#Now()#', cfsqltype='cf_sql_datetime');
                                queryService.addParam( name='ID', value='#qFind.userID#', cfsqltype='cf_sql_bigint');                                    
                                local.qUpdate = queryService.execute();

                                //update the websocket time too
                                local.queryService = new query(datasource = "#Application.datasourceName#");
                                local.sql = 'UPDATE websocket
                                            SET lastModified = :webService
                                            WHERE ID = :ID';
                                queryService.setSQL(sql);
                                
                                queryService.addParam( name='webService', value='#Now()#', cfsqltype='cf_sql_datetime');
                                queryService.addParam( name='ID', value='#qFind.ID#', cfsqltype='cf_sql_bigint');
                                
                                local.qUpdate = queryService.execute();
                                local.dt = {};
                                dt.type = 0;
                                dt.heartbeat = 1;
                                arguments.wsclient.send( serializeJSON(dt) );
                                found++;
                                break;
                            }
                        }
                        catch(any e)
                        {
                            //close connection, there is an error
                            arguments.wsclient.send( e.Message );
                            arguments.wsclient.close();
                        }
                        
                    }
                    else 
                    {
                        //close connection, something is wrong
                        arguments.wsclient.close();
                    }
                }

                if( found == 0)
                {
                   arguments.wsclient.close(); 
                }
            }            
        }
        <!------>
        local.dt = {};
        return serializeJSON(dt);

    }

    function onClose( wsclient, reasonPhrase )
    {
        var userId = getUserIdFromRequest();
        structDelete( static.clientsByUser, userId );
        structDelete( static.rolesByUser, userId );


        //delete specfic websocket from db because a new one will be started on refresh/close
        local.wsInfo = websocketInfo(false);
        local.wsInstances = wsInfo.instances;

        for ( var wsI in wsInstances ) 
        {
            //wsI.session has all info
            try 
            {
                local.queryService = new query(datasource = "#Application.datasourceName#", maxrows="1");
                local.sql = 'DELETE FROM websocket
                            WHERE websocketID = :webID AND userID = :userID';
                queryService.setSQL(sql);
                
                queryService.addParam( name='webID', value='#wsI.session.id#', cfsqltype='cf_sql_longvarchar');
                queryService.addParam( name='userID', value='#msg.userID#', cfsqltype='cf_sql_bigint');
                
                local.qClose = queryService.execute().getResult();

            }
            catch(any e)
            {
                //failed
                arguments.wsclient.send( e.Message );
            }
            
        }
    }

    function onError( wsclient, cfcatch )
    {

    }

    public static function onLastClose()
    {

    }

    // --- static helpers callable from anywhere in the app ---
    public static boolean function sendToUser( required string websocketID, required any message ) 
    {
        if ( !structKeyExists( static.clientsByUser, arguments.websocketID ) )
            {
                try 
                {
                    //not here, remove from DB
                    local.queryService = new query(datasource = static.dsName);
                    local.sql = 'DELETE FROM websocket
                                WHERE websocketID = :websocketID';
                    queryService.setSQL(sql);
                    
                    queryService.addParam( name='websocketID', value='#arguments.websocketID#', cfsqltype='cf_sql_longvarchar');
                    
                    local.qDel = queryService.execute();
                }
                catch(any e)
                {
                    //fail silently
                }
            }
            else
            {
                var cl = static.clientsByUser[ arguments.websocketID ];
                if ( !cl.isOpen() ) 
                {
                    try 
                    {
                        structDelete( static.clientsByUser, arguments.websocketID );
                        local.queryService = new query(datasource = static.dsName);
                        local.sql = 'DELETE FROM websocket
                                    WHERE websocketID = :websocketID';
                        queryService.setSQL(sql);
                        
                        queryService.addParam( name='websocketID', value='#arguments.websocketID#', cfsqltype='cf_sql_longvarchar');
                        
                        local.qDel = queryService.execute();
                    }
                    catch(any e)
                    {
                        //fail silently
                    }
                }
                else
                {
                    cl.send( arguments.message );            
                }
            }

        //collect all the socketIDs to send a message too
        /*
        local.queryService = new query(datasource = static.dsName);
        local.sql = 'SELECT ID, websocketID
                    FROM websocket
                    WHERE userID = :userID AND websocketService = :socketType';
        queryService.setSQL(sql);
        
        queryService.addParam( name='userID', value='#arguments.userID#', cfsqltype='cf_sql_bigint');
        queryService.addParam( name='socketType', value='#arguments.socketType#', cfsqltype='cf_sql_longvarchar');
        
        local.qSockets = queryService.execute().getResult();

        cfloop( query="qSockets" )
        {

            if ( !structKeyExists( static.clientsByUser, websocketID ) )
            {
                try 
                {
                    //not here, remove from DB
                    local.queryService = new query(datasource = static.dsName);
                    local.sql = 'DELETE FROM websocket
                                WHERE ID = :ID';
                    queryService.setSQL(sql);
                    
                    queryService.addParam( name='ID', value='#ID#', cfsqltype='cf_sql_bigint');
                    
                    local.qDel = queryService.execute();
                }
                catch(any e)
                {
                    //fail silently
                }
            }
            else
            {
                var cl = static.clientsByUser[ websocketID ];
                if ( !cl.isOpen() ) 
                {
                    try 
                    {
                        structDelete( static.clientsByUser, websocketID );
                        //not here, remove from DB
                        local.queryService = new query(datasource = static.dsName);
                        local.sql = 'DELETE FROM websocket
                                    WHERE ID = :ID';
                        queryService.setSQL(sql);
                        
                        queryService.addParam( name='ID', value='#ID#', cfsqltype='cf_sql_bigint');
                        
                        local.qDel = queryService.execute();
                    }
                    catch(any e)
                    {
                        //fail silently
                    }
                }
                else
                {
                    cl.send( arguments.message );            
                }
            }
        }
        */
        return true;
    }


    public static void function sendToRole( required string role, required any message ) 
    {
        for ( var userId in static.clientsByUser ) {
            if ( arrayFind( static.rolesByUser[ userId ], arguments.role ) && static.clientsByUser[ userId ].isOpen() )
                static.clientsByUser[ userId ].send( arguments.message );
        }
    }


    // --- your auth integration ---
    private string function getUserIdFromRequest() 
    {
        //get the cgi request to see who is requesting a socket connection
        local.cgiAr = ListToArray(cgi.QUERY_STRING,'&');

        if( len(local.cgiAr) != 2)
        {
            return 0;
        }

        local.uuid = cgiAr[1].ListLast( "=" );
        local.userID = cgiAr[2].ListLast("=");

        local.wsInfo = websocketInfo(false);
        local.wsInstances = wsInfo.instances;

        for ( var wsI in wsInstances ) 
        {


            if( !structKeyExists(wsI.session.requestParameter, "uuid") )
            {
                return 0;
            }

            if( !structKeyExists(wsI.session.requestParameter, "userID") )
            {
                return 0;
            }

            local.socketUUID = wsI.session.requestParameter.uuid[1];
            local.socketUserID = wsI.session.requestParameter.userID[1];


            if( local.socketUUID == local.uuid && local.socketUserID == local.userID )
            {
                try 
                {
                    local.queryService = new query(datasource = "#Application.datasourceName#", maxrows="1");
                    local.sql = 'SELECT ID, userID
                                FROM websocket
                                WHERE token = :token AND websocketID = 0 AND userID = :userID';
                    queryService.setSQL(sql);
                    
                    queryService.addParam( name='token', value='#local.uuid#', cfsqltype='cf_sql_longvarchar');
                    queryService.addParam( name='userID', value='#local.userID#', cfsqltype='cf_sql_bigint');
                    
                    local.qFind = queryService.execute().getResult();

                    if( qFind.recordcount > 0 )
                    {
                        local.queryService = new query(datasource = "#Application.datasourceName#");
                        local.sql = 'UPDATE websocket
                                    SET websocketID = :webID
                                    WHERE ID = :ID';
                        queryService.setSQL(sql);
                        
                        queryService.addParam( name='webID', value='#wsI.session.id#', cfsqltype='cf_sql_longvarchar');
                        queryService.addParam( name='ID', value='#qFind.ID#', cfsqltype='cf_sql_longvarchar');
                        
                        local.qUpdate = queryService.execute();

                        return wsI.session.id;
                    }
                }
                catch(any e)
                {
                    //failed
                    return 0;
                }
            }
        }
        
        return 0;
    }

    private array function getRolesForUser( required string userId ) 
    {
        // add your business logic here — look up roles for this user from your
        // database, auth provider, etc.
        return [ "user" ];
    }
}

And the socket connector .cfm

	<cfset sesID = "" />
	<cfset thisUUID = CreateUUID() />

	<cfif structKeyExists(Session, "readingObj")>
		<cfset sesID = Session.readingObj.getReadingSessionId() />
	</cfif>
<cfdump var="#sesID#" />
	<cfif sesID NEQ "">
		<cfset thisUUID = sesID />
	</cfif>
	
	<cfscript>
		queryService = new query(datasource = GetApplicationSettings().defaultdatasource);
		sql = 'INSERT INTO websocket
		          ( userID, token, websocketService, websocketID )
		          VALUE
		          ( :userID, :token, :msg, 0)';
		queryService.setSQL(sql);

		queryService.addParam( name='userID', value='#Session.sessionObj.getUserId()#', cfsqltype='cf_sql_bigint');
		queryService.addParam( name='token', value='#thisUUID#', cfsqltype='cf_sql_varchar');
		queryService.addParam( name='msg', value='GENERAL', cfsqltype='cf_sql_varchar');

		qWeb = queryService.execute();
	</cfscript>
<cfdump var="#thisUUID#" />
	<script type="text/javascript">
console.log('open socket');
		_socket = new WebSocket("wss://<cfoutput>#Application.websocket#/ws/websocket?uuid=#thisUUID#&userID=#Session.sessionObj.getUserId()#</cfoutput>");
		var _runTimer;

		_socket.onopen = function(evt) 
		{
console.log("Connected");
			stayActive();
			//start an interal to send a ping every 1 minute to maintain heartbeat
		};

		function stayActive()
		{
			_runTimer = setInterval(stayConnected, 5000);// was 60000
		}


		function stayConnected()
		{
			console.log('stayConnected Called from somewhere!');
			if(_socket)
			{
				let o = {}
				o.heartbeat = 1;
				o.userID = _socketUserID;

				if( o.userID != 0)
				{
					_socket.send(JSON.stringify(o));
				}
			}
		}

		if(typeof _socket !== 'undefined')
		{
			_awaitingAnswer = 0;
			_socket.onmessage = function(event) 
			{
console.log("Received onMessage:", event.data);
				//console.log( JSON.parse(event.data) );
				let resp = JSON.parse(event.data);

				if( resp.type == 0)
				{
					//heartbeat response
				}
			};

			_socket.onclose = function(evt) 
			{
				console.log('closed me off now');
				console.log(evt);
				if(typeof(runTimer) !== 'undefined') 
				{
					console.log('inverval should be cleared')
					clearInterval(runTimer);
				}
				console.log("Connection closed");
			};

			_socket.onerror = function(error)
			{
			  console.error("WebSocket error:", error);
			};
		}
</script>

The above code is used as an include on all pages, so it is the same code that runs everytime a page loads.

The reason the heartbeat is there is to keep the connection active, otherwise the browsers disconnect after a few minutes. Disabling it only causes the connection to close at some point. If I turn it off and use the form on the page to send a message to a user gives the same problem, the old socket is used, but eventually it does become the right socket.

Just an update. And I do not know if it was because I rolled back Lucee to version 7.0.3.43 because of the CFMAIL Bug (kept getting a random IMAP error, and I was not using IMAP, and spooler was not enabled), Firefox (only once so far) would not get a new socket after several hours of using the same socket. Had to log out of session and back in before Lucee would use the new socket, even though it handed out a new socket. ie, got socket 100 as a new socket, but it was still giving me socket 95.