Articles / Go

  • go
  • sigpipe
  • sockets
  • networking
  • linux
  • debugging

Go Writes to a Closed Socket Get EPIPE, Not SIGPIPE (Mostly)

If you came from C, you probably expect a write to a closed socket to kill your process with SIGPIPE. In Go it usually does not: you get an error value and carry on. But "usually" hides a rule with sharp edges, and when it does bite, the process vanishes with no panic and no log line.

The rule the runtime follows

The os/signal documentation spells it out, and it is worth reading once. Unless you have called signal.Notify for SIGPIPE, the behaviour depends on which file descriptor you wrote to:

  • A broken pipe on file descriptor 1 or 2 (stdout or stderr) makes the program exit with SIGPIPE.
  • A broken pipe on any other descriptor does nothing with the signal, and the write fails with EPIPE.
  • If you have called signal.Notify for SIGPIPE, your channel receives it for a broken pipe on any descriptor.

A TCP connection from net.Dial or Accept is some descriptor well above 2, so the middle case applies. The runtime installs its own handler that swallows the signal, and your Write returns an error.

Seeing it happen

Here is a client writing to a peer that has already closed:

package main

import (
	"errors"
	"fmt"
	"net"
	"syscall"
	"time"
)

func main() {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	go func() {
		c, err := ln.Accept()
		if err == nil {
			c.Close()
		}
	}()

	conn, err := net.Dial("tcp", ln.Addr().String())
	if err != nil {
		panic(err)
	}
	time.Sleep(100 * time.Millisecond) // let the server close

	for i := 1; i <= 3; i++ {
		_, err := conn.Write([]byte("hello"))
		fmt.Println(i, err, errors.Is(err, syscall.EPIPE))
	}
}

The process survives. On Linux you will typically see the first write succeed, then write: broken pipe (or sometimes connection reset by peer) afterwards.

Why the first write succeeds

Quick detour, because this catches people out. The first write to a closed peer usually reports success. The kernel copies your bytes into the send buffer and sends them; only then does the peer answer with a RST, because it has no socket any more.

The RST arrives after Write has returned. The next write sees the socket in an error state and fails. So a successful write never proves the other side received anything; it only means the kernel accepted the bytes.

Handling the error properly

Treat EPIPE and ECONNRESET as "the peer went away", not as a bug in your code. Log it at debug level, drop the connection, move on:

func isPeerGone(err error) bool {
	return errors.Is(err, syscall.EPIPE) ||
		errors.Is(err, syscall.ECONNRESET) ||
		errors.Is(err, net.ErrClosed)
}

Both errors are wrapped inside *net.OpError, and that wraps an *os.SyscallError, so errors.Is reaches them. Comparing with == will not.

When the process does die

These are the cases where SIGPIPE really does end your program:

  1. Writing to stdout or stderr when the reader has gone. mytool | head -1 is the classic: head exits, the next fmt.Println hits a broken pipe on fd 1, and Go exits quietly. That is deliberate and matches how Unix tools behave.
  2. A socket that is your stdout. Under inetd-style launching, or systemd with StandardOutput=socket, fd 1 is the connection. A peer disconnect then kills the process rather than returning an error, even though it is a socket.
  3. cgo and C libraries. A C library writing to a socket from its own code does not go through Go's os checks, and library behaviour with SIGPIPE varies. Check what the library does; many set MSG_NOSIGNAL themselves.
  4. Shared libraries built from Go. The os/signal documentation has separate notes for c-archive and c-shared build modes, where the host program may own signal handling.

Fixing the stdout case

If a long-running daemon logs to stdout and its log reader can vanish, a SIGPIPE exit is a nasty surprise. Calling signal.Ignore(syscall.SIGPIPE) changes it so writes to fd 1 and 2 fail with EPIPE instead, which you can then handle. I would do this only for a program where you genuinely want to carry on without logs; for a CLI filter, dying quietly is the correct behaviour.

The opposite trap is signal.Notify(ch, syscall.SIGPIPE). Registering for it makes the signal arrive on any broken pipe, including every closed client socket. If nothing reads ch, the buffer fills and the notifications are dropped, which is harmless; but a handler that shuts the program down on any signal will now stop your server whenever one client disconnects.

A quick way to check which case you are in

If a Go process disappears with no output, check how it ended:

  • Run it under a shell and look at $?: 141 means it died of signal 13 (128 plus 13).
  • Under systemd, systemctl status reports "code=killed, signal=PIPE".
  • Run it under strace -f -e trace=write,kill and look for EPIPE followed by SIGPIPE on a low-numbered descriptor.

If the descriptor is 1 or 2, you have your answer. If it is a high-numbered socket, something else (a notify handler, or C code) is involved.