Skip to content

Commit 0580002

Browse files
myleshortonclaude
andcommitted
roundtrip: unit-test h2Body close-error propagation
Make h2Body.cc an io.Closer (a *http2.ClientConn in practice) so the teardown precedence is unit-testable, and add TestH2Body_Close covering: the cc error surfaces when body close succeeds, a body-close error takes precedence, and the connection is closed exactly once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f330270 commit 0580002

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

h2_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package domainfront
33
import (
44
stdtls "crypto/tls"
55
"crypto/x509"
6+
"errors"
67
"io"
78
"log/slog"
89
"net"
@@ -103,6 +104,47 @@ func TestVerifyWithPost_HTTP2(t *testing.T) {
103104
assert.Equal(t, http.MethodPost, <-gotMethod)
104105
}
105106

107+
// errCloser is a test io.Closer that records call count and returns a fixed err.
108+
type errCloser struct {
109+
err error
110+
calls int
111+
}
112+
113+
func (c *errCloser) Close() error { c.calls++; return c.err }
114+
115+
// TestH2Body_Close covers the connection-teardown semantics: the underlying h2
116+
// connection is closed exactly once, its error surfaces when the body close
117+
// itself succeeds, and a body-close error takes precedence.
118+
func TestH2Body_Close(t *testing.T) {
119+
t.Run("propagates cc error when body close ok", func(t *testing.T) {
120+
cc := &errCloser{err: assert.AnError}
121+
b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc}
122+
assert.Equal(t, assert.AnError, b.Close())
123+
assert.Equal(t, 1, cc.calls)
124+
})
125+
126+
t.Run("body error takes precedence over cc error", func(t *testing.T) {
127+
bodyErr := errors.New("body boom")
128+
cc := &errCloser{err: assert.AnError}
129+
b := &h2Body{ReadCloser: errReadCloser{bodyErr}, cc: cc}
130+
assert.Equal(t, bodyErr, b.Close())
131+
assert.Equal(t, 1, cc.calls, "cc still closed even when body close fails")
132+
})
133+
134+
t.Run("closes the connection only once", func(t *testing.T) {
135+
cc := &errCloser{}
136+
b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc}
137+
assert.NoError(t, b.Close())
138+
assert.NoError(t, b.Close())
139+
assert.Equal(t, 1, cc.calls)
140+
})
141+
}
142+
143+
type errReadCloser struct{ err error }
144+
145+
func (e errReadCloser) Read([]byte) (int, error) { return 0, e.err }
146+
func (e errReadCloser) Close() error { return e.err }
147+
106148
func TestHasConnectionUpgrade(t *testing.T) {
107149
mk := func(vals ...string) *http.Request {
108150
r, _ := http.NewRequest(http.MethodGet, "https://x/", nil)

roundtrip.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,11 @@ func roundTripH2(conn net.Conn, req *http.Request) (*http.Response, error) {
167167
}
168168

169169
// h2Body closes the underlying HTTP/2 connection when the response body is
170-
// closed, since each connection serves exactly one request.
170+
// closed, since each connection serves exactly one request. cc is an io.Closer
171+
// (a *http2.ClientConn in practice) so the teardown logic stays unit-testable.
171172
type h2Body struct {
172173
io.ReadCloser
173-
cc *http2.ClientConn
174+
cc io.Closer
174175
once sync.Once
175176
}
176177

0 commit comments

Comments
 (0)