cloudflare/cloudflared · error

write to closed websocket connection

Error message

write to closed websocket connection

What it means

websocket/connection.go's Conn.Write guards against writing after Close was called (fix for TUN-5184). If the connection is marked done, it returns this error instead of passing bytes down to wsutil, which would otherwise panic or silently corrupt state. It signals the caller that the websocket lifecycle has ended and the write was discarded (0 bytes written).

Source

Thrown at websocket/connection.go:115

	return c
}

// Read will read messages from the websocket connection
func (c *Conn) Read(reader []byte) (int, error) {
	data, err := wsutil.ReadClientBinary(c.rw)
	if err != nil {
		return 0, err
	}
	return copy(reader, data), nil
}

// Write will write messages to the websocket connection.
// It will not write to the connection after Close is called to fix TUN-5184
func (c *Conn) Write(p []byte) (int, error) {
	c.writeLock.Lock()
	defer c.writeLock.Unlock()
	if c.done {
		return 0, errors.New("write to closed websocket connection")
	}
	if err := wsutil.WriteServerBinary(c.rw, p); err != nil {
		return 0, err
	}

	return len(p), nil
}

func (c *Conn) pinger(ctx context.Context) {
	pongMessge := wsutil.Message{
		OpCode:  gobwas.OpPong,
		Payload: []byte{},
	}

	ticker := time.NewTicker(c.pingPeriod(ctx))
	defer ticker.Stop()
	for {
		select {

View on GitHub (pinned to 2253eeeb25)

Solutions

  1. Check connection state before writing: stop the write loop when Close/done is signaled (e.g. select on a done channel).
  2. Treat this error as a benign shutdown signal — log at debug and break out of the write loop rather than retrying.
  3. Serialize lifecycle: ensure Close() is only called after all in-flight writers have returned, or guard writes with the same done check.
  4. If writes regularly race with close, restructure so the owning goroutine closes the connection last.

Example fix

// before: blind write
n, err := wsConn.Write(data)
// after: tolerate closed-connection teardown
n, err := wsConn.Write(data)
if err != nil {
	if err.Error() == "write to closed websocket connection" {
		return nil // connection already torn down
	}
	return err
}
Defensive patterns

Strategy: type-guard

Validate before calling

// guard: only write while the connection is open
if wsConn == nil || wsConn.IsClosed() {
    return errors.New("websocket not open; skipping write")
}

Type guard

func writable(c *websocket.Conn) bool {
    return c != nil && !c.IsClosed()
}

Try / catch

n, err := wsConn.Write(p)
if err != nil {
    if strings.Contains(err.Error(), "write to closed websocket connection") {
        return 0, nil // expected during shutdown
    }
    return n, err
}

Prevention

When it happens

Trigger: Any caller of (*websocket.Conn).Write — e.g. echoTCPOrigin, WriteEvent, echoTCP — invoking Write after another goroutine has called Close() on the same Conn (c.done set to true under writeLock).

Common situations: A proxied TCP/SSH session where the client disconnected and the origin loop still tries to flush buffered data; a stream/heartbeat (WriteEvent) racing with connection teardown during shutdown; writing to a websocket whose dial failed or whose reader already exited.

Related errors


AI-assisted analysis of cloudflare/cloudflared@2253eeeb25 (2026-09-06). Data as JSON: /api/errors/85e8e76e2eb942ac. Report an issue: GitHub.