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.Notifyfor 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:
- Writing to stdout or stderr when the reader has gone.
mytool | head -1is the classic:headexits, the nextfmt.Printlnhits a broken pipe on fd 1, and Go exits quietly. That is deliberate and matches how Unix tools behave. - 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. - cgo and C libraries. A C library writing to a socket from its own code does not go through Go's
oschecks, and library behaviour with SIGPIPE varies. Check what the library does; many setMSG_NOSIGNALthemselves. - Shared libraries built from Go. The
os/signaldocumentation has separate notes forc-archiveandc-sharedbuild 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 statusreports "code=killed, signal=PIPE". - Run it under
strace -f -e trace=write,killand look forEPIPEfollowed bySIGPIPEon 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.