markovchain
markovchain

Reputation: 503

How to kill node.js server connection when client browser exits?

I'm trying to write a simple node.js server with Server Sent Event abilities without socket.io. My code runs well enough, but I ran into a huge problem when I was testing it.

If I connect to the node.js server from my browser, it will receive the server events fine. But when I refresh the connection, the same browser will start to receive the event twice. If I refresh n times, the browser will receive data n+1 times.

The most I tried this out with was 16 times (16 refreshes) before I stopped trying to count it.

See the screenshot below of the console from my browser. After the refresh (it tries to make an AJAX call), it will output "SSE persistent connection established!" and afterwards it will start receiving events.

Note the time when the events arrive. I get the event twice at 19:34:07 (it logs to the console twice -- upon receipt of the event and upon writing of the data to the screen; so you can see four logs there).

I also get the event twice at 19:34:12.

client side

Here's what it looks like at the server side after the client has closed the connection (with source.close() ):

server side

As you can see, it is still trying to send messages to the client! Also, it's trying to send the messages twice (so I know this is a server side problem)!

I know it tries to send twice because it sent two heartbeats when it's only supposed to send once per 30 seconds.

This problem magnifies when open n tabs. What happens is each open tab will receive n*n events. Basically, how I interpret it is this:

Opening the first tab, I subscribe to the server once. Opening the second tab, I subscribe both open tabs to the server once again -- so that's 2 subscriptions per tab. Opening a third tab, I subscribe all three open tabs to the events once more, so 3 subscriptions per tab, total of 9. And so on...

I can't verify this, but my guess is that if I can create one subscription, I should be able to unsubscribe if certain conditions are met (ie, heartbeat fails, I must disconnect). And the reason this is happening is only because of the following:

  1. I started setInterval once, and an instance of it will forever run unless I stop it, or
  2. The server is still trying to send data to the client trying to keep the connection open?

As for 1., I've already tried to kill the setInterval with clearInterval, it doesn't work. As for 2, while it's probably impossible, I'm leaning towards believing that...

Here's server side code snippets of just the relevant parts (editing the code after the suggestions from answers):

server = http.createServer(function(req, res){
    heartbeatPulse(res);
}).listen(8888, '127.0.0.1', 511);

server.on("request", function(req, res){
    console.log("Request Received!");

    var route_origin = url.parse(req.url);

    if(route_origin.query !== null){
        serveAJAX(res);
    } else {
        serveSSE(res);
        //hbTimerID = timerID.hbID;
        //msgTimerID = timerID.msgID;
    }

    req.on("close", function(){
        console.log("close the connection!");
        res.end();
        clearInterval(res['hbID']);
        clearInterval(res['msgID']);
        console.log(res['hbID']);
        console.log(res['msgID']);
    });
});

var attachListeners = function(res){
    /*
        Adding listeners to the server
        These events are triggered only when the server communicates by SSE
    */

    // this listener listens for file changes -- needs more modification to accommodate which file changed, what to write
    server.addListener("file_changed", function(res){
        // replace this with a file reader function
        writeUpdate = res.write("data: {\"date\": \"" + Date() + "\", \"test\": \"nowsssss\", \"i\": " + i++ + "}\n\n");

        if(writeUpdate){
            console.log("Sent SSE!");
        } else {
            console.log("SSE failed to send!");
        }
    });

    // this listener enables the server to send heartbeats
    server.addListener("hb", function(res){

        if(res.write("event: hb\ndata:\nretry: 5000\n\n")){
            console.log("Sent HB!");
        } else {
            // this fails. We can't just close the response, we need to close the connection
            // how to close a connection upon the client closing the browser??
            console.log("HB failed! Closing connection to client...");
            res.end();
            //console.log(http.IncomingMessage.connection);
            //http.IncomingMessage.complete = true;
            clearInterval(res['hbID']);
            clearInterval(res['msgID']);
            console.log(res['hbID']);
            console.log(res['msgID']);
            console.log("\tConnection Closed.");
        }
    });
}

var heartbeatPulse = function(res){
    res['hbID'] = "";
    res['msgID'] = "";

    res['hbID'] = setInterval(function(){
        server.emit("hb", res);
    }, HB_period);

    res['msgID'] = setInterval(function(){
        server.emit("file_changed", res);
    }, 5000);

    console.log(res['hbID']);
    console.log(res['msgID'])

    /*return {
        hbID: hbID,
        msgID: msgID
    };*/
}

var serveSSE = function(res){
    res.writeHead(200, {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        "Access-Control-Allow-Origin": "*",
        "Connection": "keep-alive"
    });

    console.log("Establishing Persistent Connection...");
    if(res.write("event: connector\ndata:\nretry: 5000\n\n")){
        // Only upon receiving the first message will the headers be sent
        console.log("Established Persistent Connection!");
    }

    attachListeners(res);

    console.log("\tRequested via SSE!");
}

This is largely a self project for learning, so any comments are definitely welcome.

Upvotes: 1

Views: 3132

Answers (2)

markovchain
markovchain

Reputation: 503

Here's how I got it to work:

  1. Made a global heart object that contains the server plus all the methods to modify the server

    var http = require("http");
    var url = require("url");
    var i = 0; // for dev only
    
    var heart = {
        server: {},
        create: function(object){
            if(!object){
                return false;
            }
    
            this.server = http.createServer().listen(8888, '127.0.0.1');
            if(!this.server){
                return false;
            }
    
            for(each in object){
                if(!this.listen("hb", object[each])){
                    return false;
                }
            }
    
            return true;
        },
        listen: function(event, callback){
            return this.server.addListener(event, callback);
        },
        ignore: function(event, callback){
            if(!callback){
                return this.server.removeAllListeners(event);
            } else {
                return this.server.removeListener(event, callback);
            }
        },
        emit: function(event){
            return this.server.emit(event);
        },
        on: function(event, callback){
            return this.server.on(event, callback);
        },
        beating: 0,
        beatPeriod: 1000,
        lastBeat: false,
        beat: function(){
            if(this.beating === 0){
                this.beating = setInterval(function(){
                    heart.lastBeat = heart.emit("hb");
                }, this.beatPeriod);
    
                return true;
            } else {
    
                return false;
            }
        },
        stop: function(){ // not applicable if I always want the heart to beat
            if(this.beating !== 0){
                this.ignore("hb");
                clearInterval(this.beating);
                this.beating = 0;
    
                return true;
            } else {
    
                return false;
            }
        },
        methods: {},
        append: function(name, method){
            if(this.methods[name] = method){
                return true;
            }
    
            return false;
        }
    };
    
    /*
        Starting the heart
    */
    if(heart.create(object) && heart.beat()){
        console.log("Heart is beating!");
    } else {
        console.log("Failed to start the heart!");
    }
    
  2. I chained the req.on("close", callback) listener on the (essentially) server.on("request", callback) listener, then remove the callback if the close event is triggered

  3. I chained a server.on("heartbeat", callback) listener on the server.on("request", callback) listener and made a res.write() when a heartbeat is triggered

  4. The end result is each response object is being dictated by the server's single heartbeat timer, and will remove its own listeners when the request is closed.

    heart.on("request", function(req, res){ console.log("Someone Requested!");

    var origin = url.parse(req.url).query;
    if(origin === "ajax"){
        res.writeHead(200, {
            "Content-Type": "text/plain",
            "Access-Control-Allow-Origin": "*",
            "Connection": "close"
        });
        res.write("{\"i\":\"" + i + "\",\"now\":\"" + Date() + "\"}"); // this needs to be a file reading function
        res.end();
    } else {
        var hbcallback = function(){
            console.log("Heartbeat detected!");
            if(!res.write("event: hb\ndata:\n\n")){
                console.log("Failed to send heartbeat!");
            } else {
                console.log("Succeeded in sending heartbeat!");
            }
        };
    
        res.writeHead(200, {
            "Content-Type": "text/event-stream",
            "Cache-Control": "no-cache",
            "Access-Control-Allow-Origin": "*",
            "Connection": "keep-alive"
        });
    
        heart.on("hb", hbcallback);
    
        req.on("close", function(){
            console.log("The client disconnected!");
            heart.ignore("hb", hbcallback);
            res.end();
        });
    }
    

    });

Upvotes: 0

mscdex
mscdex

Reputation: 106696

One issue is that you are storing request-specific variables outside the scope of the http server. So what you could do instead is to just call your setInterval()s once right after you start the http server and not start individual timers per-request.

An alternative to adding event handlers for every request might be to instead add the response object to an array that is looped through inside each setInterval() callback, writing to the response. When the connection closes, remove the response object from the array.

The second issue about detecting a dead connection can be fixed by listening for the close event on the req object. When that is emitted, you remove the server event (e.g. file_changed and hb) listeners you added for that connection and do whatever other necessary cleanup.

Upvotes: 1

Related Questions