While testing an internal HTTP service against Go 1.27, a handler that
had worked for years started reporting errors from an unlikely place:
r.Body.Close().
The handler does what any length-aware decoder does — it reads exactly
the payload it expects and stops, without reading far enough to observe
io.EOF. Then it closes the body. On Go 1.26 that Close returned
nil. On 1.27 it returns io.EOF.
Pinning it down
Timer-driven HTTP repros are flaky, so I drove net/http over a
net.Pipe with a one-shot listener: fully deterministic, no real
sockets, no timeouts. The whole thing fits in a
playground link. A handler reads 2
of 4 body bytes, closes, and reports the error:
req.Body.Close() = EOF
want <nil>
Bisecting pointed at CL 794640,
“net/http: don’t rely on server request body not changing”, which
consolidated server-side body draining into (*body).Close. The old
drain path cleared the sentinel after a successful drain:
n, err = io.CopyN(io.Discard, bodyLocked{b}, maxPostHandlerReadBytes)
if err == io.EOF {
err = nil
}
The refactor kept the drain, improved the bookkeeping — and dropped the
err = nil. Draining to EOF is the success path here, but the
sentinel now leaks out of Close. The CL message describes a pure
refactor, so the behavior change looks unintended.
Upstream
Filed as golang/go#80964,
fix sent as golang/go#80968:
restore the clear inside the new EOF branch, keeping the CL’s
maxPostHandlerReadBytes+1 read bound and the earlyClose/sawEOF
bookkeeping intact. One line, plus a regression test.
Takeaway
Close errors are contract, even when nobody documents them. A refactor
that only “moves code around” can still change what callers observe on
the error path — and somewhere, a decoder that trusts its
Content-Length will notice.