Heartbeats and Disconnects

Ping, pong and read deadlines turn a silent connection into an error you can act on, then clean up after it exactly once.

Syntaxconn.SetReadDeadline(time.Now().Add(pongWait)) / conn.SetPongHandler(func(appData string) error) / conn.WriteControl(websocket.PingMessage, nil, deadline)

A TCP connection does not tell you when the other end disappears. A laptop lid closes or a phone leaves the network, and the server goes on holding a connection and two goroutines for a client that will never speak again.

The protocol has an answer built in. One side sends a ping frame and the other must answer with a pong frame. Browsers do this themselves, and so does the default handler in gorilla/websocket. A pong is proof that the client was alive a moment ago.

Deadlines turn silence into an error

A read deadline is a time after which a read fails. Set it to now plus the longest silence you will accept, commonly 60 seconds, and extend it on every pong. Have the writing goroutine send a ping a little more often than that, 54 seconds against a 60-second deadline, so a healthy client always refreshes it in time. If the client is gone no pong arrives, the deadline passes, and ReadMessage returns an error. That error is your disconnect notification. Set a write deadline too, so a client that has stopped reading cannot hold a goroutine forever.

Cleaning up

Do the cleanup in one place, usually a deferred function: close the connection, remove the client from whatever set holds it, and let the writing goroutine finish. SSE has no ping frame, so write a comment line instead. A line of : ping followed by a blank line puts bytes on the wire and nothing on the page.

What trips people up

Pongs are only delivered while some goroutine is inside ReadMessage. A connection that only writes never notices its own read deadline. Send pings with WriteControl, which is the one write method safe to call while another goroutine is writing a message.

Example

Example · go
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"

	"github.com/gorilla/websocket"
)

// A real server allows about 60 seconds of silence. This one is impatient so
// that the program finishes.
const pongWait = 300 * time.Millisecond

var (
	upgrader = websocket.Upgrader{}
	done     = make(chan struct{})
)

// serve pings the client, then reads until the read deadline passes.
func serve(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}
	defer func() { // the one cleanup path, however the connection ends
		conn.Close()
		fmt.Println("cleanup: client removed from the hub")
		close(done)
	}()
	if err := conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
		return
	}
	conn.SetPongHandler(func(string) error { // a pong pushes the deadline out
		fmt.Println("pong: the client is still there")
		return conn.SetReadDeadline(time.Now().Add(pongWait))
	})
	if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil {
		return
	}
	for {
		if _, _, err := conn.ReadMessage(); err != nil {
			return // the passed deadline arrives here as an error
		}
	}
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(serve))
	defer srv.Close()
	conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()
	// Reading is what lets the library answer the ping. This read ends when the
	// server gives up on a silent client and closes the connection.
	if _, _, err := conn.ReadMessage(); err == nil {
		log.Fatal("expected the server to hang up")
	}
	<-done
}

// Output:
// pong: the client is still there
// cleanup: client removed from the hub

When to use it

  • Free the goroutines held by phones that walked out of network range.
  • Keep a connection open through a proxy that closes anything idle for a minute.
  • Remove a vanished member from a chat room's list as soon as it is provable.

More examples

The standard read and write pumps

The three constants and the two loops that production WebSocket code is built from. pingPeriod is nine tenths of pongWait so a healthy client always answers before the deadline it is being measured against. The reader does nothing but keep the deadline alive and read; the writer sends real messages and, in the gaps, pings. Copy this pair, then add your own handling where the reader logs.

Example · go
package chat

import (
	"time"

	"github.com/gorilla/websocket"
)

const (
	pongWait   = 60 * time.Second  // no pong in this long means the client is gone
	pingPeriod = pongWait * 9 / 10 // ping with time to spare before that
	writeWait  = 10 * time.Second  // a single write may take this long
)

type client struct {
	ws   *websocket.Conn
	send chan []byte
}

// readPump keeps the read deadline alive. Each pong pushes it out again.
func (c *client) readPump() {
	defer c.ws.Close()
	c.ws.SetReadLimit(4096)
	if err := c.ws.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
		return
	}
	c.ws.SetPongHandler(func(string) error {
		return c.ws.SetReadDeadline(time.Now().Add(pongWait))
	})
	for {
		if _, _, err := c.ws.ReadMessage(); err != nil {
			return // a read deadline counts as an error, which is the point
		}
	}
}

// writePump sends application messages and, between them, pings.
func (c *client) writePump() {
	ticker := time.NewTicker(pingPeriod)
	defer func() {
		ticker.Stop()
		c.ws.Close()
	}()
	for {
		select {
		case data, open := <-c.send:
			if !open {
				return // the hub dropped this client
			}
			if err := c.ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
				return
			}
			if err := c.ws.WriteMessage(websocket.TextMessage, data); err != nil {
				return
			}
		case <-ticker.C:
			// WriteControl is safe to call while another goroutine writes a
			// data message, and it takes its own deadline.
			if err := c.ws.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil {
				return
			}
		}
	}
}

Closing on purpose, and logging only real failures

Conn.Close drops the TCP connection without telling the client why, which browsers treat as a failure and retry fast. Sending a close frame first makes an intentional shutdown look intentional. On the read side, codes 1000 and 1001 are a closed or reloaded tab, so IsUnexpectedCloseError lets you log the disconnects that actually need attention and ignore the thousands that do not. Use both when a deploy is about to end every connection you hold.

Example · go
package chat

import (
	"log"
	"time"

	"github.com/gorilla/websocket"
)

// closeGracefully asks the client to go away, then hangs up. Browsers reconnect
// faster after a close frame than after a dropped TCP connection.
func closeGracefully(conn *websocket.Conn, reason string) {
	msg := websocket.FormatCloseMessage(websocket.CloseGoingAway, reason)
	if err := conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second)); err != nil {
		log.Printf("close frame not sent: %v", err)
	}
	conn.Close() // Close never sends a close frame by itself
}

// readUntilGone logs only the disconnects that are worth looking at.
func readUntilGone(conn *websocket.Conn) {
	for {
		_, _, err := conn.ReadMessage()
		if err == nil {
			continue
		}
		// 1000 and 1001 are a tab being closed or reloaded: ordinary life.
		if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
			log.Printf("unexpected disconnect: %v", err)
		}
		return
	}
}

The same job for an SSE stream

SSE has no control frames, so the heartbeat is a comment line: the browser ignores it, while the proxy in between sees traffic and leaves the connection alone. The write deadline matters more here than people expect. Without it a client that has stopped reading leaves this handler blocked in a write for as long as the kernel buffer stays full, holding a goroutine and a connection.

Example · go
package stream

import (
	"fmt"
	"net/http"
	"time"
)

// SSE has no ping frame. A comment line does the same job: it is bytes on the
// wire, so proxies and load balancers do not see an idle connection.
func stream(w http.ResponseWriter, r *http.Request, updates <-chan string) {
	w.Header().Set("Content-Type", "text/event-stream")
	rc := http.NewResponseController(w)

	beat := time.NewTicker(20 * time.Second)
	defer beat.Stop()

	for {
		select {
		case <-r.Context().Done():
			return
		case msg := <-updates:
			// A write deadline turns a stuck client into an error instead of a
			// goroutine that waits forever. Set it here, once there is
			// something to write: a deadline set before the select would
			// already have passed while this loop waited for the next event.
			if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
				return
			}
			fmt.Fprintf(w, "data: %s\n\n", msg)
		case <-beat.C:
			if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
				return
			}
			fmt.Fprint(w, ": ping\n\n") // a line starting with ':' is a comment
		}
		if err := rc.Flush(); err != nil {
			return
		}
	}
}

Discussion

  • Be the first to comment on this lesson.