diff --git a/.golangci.yml b/.golangci.yml index affd69c8..3e917ed3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,6 +5,7 @@ linters: - depguard - err113 # disabled temporarily: there are just too many issues to address - exhaustruct + - exhaustruct_v5 - funlen - gochecknoglobals - gochecknoinits diff --git a/multipart_stream.go b/multipart_stream.go index 76e1bf46..78bc9755 100644 --- a/multipart_stream.go +++ b/multipart_stream.go @@ -342,6 +342,10 @@ func (s *MultipartFormStream) Drain() error { // particular, a net/http server request body may discard a limited amount of // unread data to allow connection reuse, so Close is not guaranteed to return // immediately. Call Drain when trailing form fields must be collected. +// +// Close returns the error reported by the request body, except io.EOF: a +// net/http server body reports io.EOF once it discards that unread remainder, +// which tells us the body ended, not that closing it failed. func (s *MultipartFormStream) Close() error { if s == nil || s.closed { return nil @@ -357,7 +361,14 @@ func (s *MultipartFormStream) Close() error { return nil } - return s.request.Body.Close() + // Since go1.27.0, net/http (*body).Close returns io.EOF when it discards + // the unread remainder of the request itself. Reaching the end of the body + // is not a close failure. + if err := s.request.Body.Close(); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil } func supportsMultipartFormStream(method string) bool { diff --git a/multipart_stream_test.go b/multipart_stream_test.go index a66604e2..85f61b04 100644 --- a/multipart_stream_test.go +++ b/multipart_stream_test.go @@ -325,6 +325,44 @@ func TestMultipartFormStreamDrainCollectsTrailingFieldsAndClosesBody(t *testing. require.ErrorIs(t, err, io.EOF) } +func TestMultipartFormStreamCloseIgnoresBodyCloseEOF(t *testing.T) { + // Since go1.27.0, a net/http server request body reports io.EOF from Close + // when it discards the unread remainder of the request. The stream must not + // pass that on as a failure. + body, contentType := orderedMultipartBody(t, + orderedFile{field: testFieldFile, filename: streamedFilename, content: "payload"}, + orderedField{name: "after", value: "value"}, + ) + trackedBody := &observableReadCloser{Reader: body, closeErr: io.EOF} + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, testUploadPath, nil) + request.Body = trackedBody + request.Header.Set(HeaderContentType, contentType) + stream, err := NewMultipartFormStream(request) + require.NoError(t, err) + + _, err = stream.NextFile() + require.NoError(t, err) + require.NoError(t, stream.Drain()) + assert.EqualT(t, "value", request.Form.Get("after")) + assert.TrueT(t, trackedBody.Closed()) +} + +func TestMultipartFormStreamCloseReportsBodyCloseError(t *testing.T) { + body, contentType := orderedMultipartBody(t, + orderedFile{field: testFieldFile, filename: streamedFilename, content: "payload"}, + ) + closeErr := stderrors.New("close failed") + trackedBody := &observableReadCloser{Reader: body, closeErr: closeErr} + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, testUploadPath, nil) + request.Body = trackedBody + request.Header.Set(HeaderContentType, contentType) + stream, err := NewMultipartFormStream(request) + require.NoError(t, err) + + require.ErrorIs(t, stream.Close(), closeErr) + assert.TrueT(t, trackedBody.Closed()) +} + func TestMultipartFormStreamCloseAbortsWithoutDraining(t *testing.T) { body, contentType := orderedMultipartBody(t, orderedFile{field: testFieldFile, filename: streamedFilename, content: "payload"}, @@ -626,6 +664,8 @@ func orderedMultipartBody(t *testing.T, parts ...orderedMultipartPart) (*bytes.B type observableReadCloser struct { io.Reader + closeErr error + mu sync.Mutex closed bool } @@ -635,7 +675,7 @@ func (r *observableReadCloser) Close() error { defer r.mu.Unlock() r.closed = true - return nil + return r.closeErr } func (r *observableReadCloser) Closed() bool {