Skip to content

Commit a37d5e5

Browse files
Merge pull request #23 from hayabusa-cloud/fix
fix: return ErrUnexpectedEOF on short copies
2 parents cd796d5 + 7789ecd commit a37d5e5

2 files changed

Lines changed: 105 additions & 9 deletions

File tree

io.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,14 @@ func CopyNPolicy(dst Writer, src Reader, n int64, policy SemanticPolicy) (writte
135135
return CopyN(dst, src, n)
136136
}
137137
lr := limitedReader{R: src, N: n}
138-
return copyBufferPolicy(dst, &lr, nil, policy)
138+
written, err = copyBufferPolicy(dst, &lr, nil, policy)
139+
if written == n {
140+
return n, nil
141+
}
142+
if err == nil || err == io.EOF {
143+
return written, io.ErrUnexpectedEOF
144+
}
145+
return written, err
139146
}
140147

141148
// CopyNBuffer is like CopyN but stages through buf if needed.
@@ -177,7 +184,14 @@ func CopyNBufferPolicy(dst Writer, src Reader, n int64, buf []byte, policy Seman
177184
return CopyNBuffer(dst, src, n, buf)
178185
}
179186
lr := limitedReader{R: src, N: n}
180-
return copyBufferPolicy(dst, &lr, buf, policy)
187+
written, err = copyBufferPolicy(dst, &lr, buf, policy)
188+
if written == n {
189+
return n, nil
190+
}
191+
if err == nil || err == io.EOF {
192+
return written, io.ErrUnexpectedEOF
193+
}
194+
return written, err
181195
}
182196

183197
type limitedReader struct {
@@ -383,6 +397,12 @@ func copyBufferPolicy(dst Writer, src Reader, buf []byte, policy SemanticPolicy)
383397
}
384398
return written, ErrMore
385399
}
400+
// Attempt Seeker rollback on partial write with semantic error.
401+
if off < nr && IsSemantic(ew) {
402+
if rollbackErr := rollbackSeeker(src, off-nr); rollbackErr != nil {
403+
return written, rollbackErr
404+
}
405+
}
386406
return written, ew
387407
}
388408
if nw == 0 {

policy_test.go

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package iox_test
77
import (
88
"bytes"
99
"errors"
10+
"fmt"
1011
"io"
1112
"testing"
1213

@@ -457,22 +458,21 @@ func TestCopyPolicy_ReaderFromFastPath_More_Returns(t *testing.T) {
457458
t.Fatalf("want More from ReaderFrom fast path: n=%d err=%v", n, err)
458459
}
459460
}
460-
func TestCopyNPolicy_Short_NoUnexpectedEOF_NoErrShort(t *testing.T) {
461+
func TestCopyNPolicy_Short_ReturnsUnexpectedEOF(t *testing.T) {
461462
var dst bytes.Buffer
462463
// src returns fewer than N without error → UnexpectedEOF
463464
src := bytes.NewBufferString("ab")
464465
n, err := iox.CopyNPolicy(&dst, src, 3, iox.YieldPolicy{})
465-
// Current CopyNPolicy returns underlying result directly (no UnexpectedEOF mapping)
466-
if err != nil || n != 2 {
467-
t.Fatalf("want (2,nil) got n=%d err=%v", n, err)
466+
if err != io.ErrUnexpectedEOF || n != 2 {
467+
t.Fatalf("want (2,ErrUnexpectedEOF) got n=%d err=%v", n, err)
468468
}
469469
}
470-
func TestCopyNPolicy_ShortEOF_NoUnexpectedEOF(t *testing.T) {
470+
func TestCopyNPolicy_ShortEOF_ReturnsUnexpectedEOF(t *testing.T) {
471471
var dst bytes.Buffer
472472
src := bytes.NewBufferString("a")
473473
n, err := iox.CopyNPolicy(&dst, src, 2, iox.YieldPolicy{})
474-
if err != nil || n != 1 {
475-
t.Fatalf("want (1,nil) got n=%d err=%v", n, err)
474+
if err != io.ErrUnexpectedEOF || n != 1 {
475+
t.Fatalf("want (1,ErrUnexpectedEOF) got n=%d err=%v", n, err)
476476
}
477477
}
478478
func TestCopyNBufferPolicy_ExactN_WithBuf(t *testing.T) {
@@ -848,3 +848,79 @@ func TestTeeWriterPolicy_PrimaryWouldBlockOnceCompletes(t *testing.T) {
848848
t.Fatalf("primary=%q tee=%q", p.buf.String(), tbuf.String())
849849
}
850850
}
851+
852+
// --- Coverage tests for uncovered patch lines ---
853+
854+
// CopyNPolicy: non-nil/non-EOF error passthrough.
855+
func TestCopyNPolicy_ErrorPassthrough(t *testing.T) {
856+
readErr := errors.New("read-fail")
857+
src := &dataThenErrReader{data: []byte("a"), err: readErr}
858+
var dst bytes.Buffer
859+
n, err := iox.CopyNPolicy(&dst, src, 5, iox.YieldPolicy{})
860+
if !errors.Is(err, readErr) || n != 1 {
861+
t.Fatalf("want (1, read-fail) got (%d, %v)", n, err)
862+
}
863+
}
864+
865+
// CopyNBufferPolicy: short copy → ErrUnexpectedEOF.
866+
func TestCopyNBufferPolicy_Short_ReturnsUnexpectedEOF(t *testing.T) {
867+
src := bytes.NewBufferString("ab")
868+
var dst bytes.Buffer
869+
buf := make([]byte, 4)
870+
n, err := iox.CopyNBufferPolicy(&dst, src, 5, buf, iox.YieldPolicy{})
871+
if err != io.ErrUnexpectedEOF || n != 2 {
872+
t.Fatalf("want (2, ErrUnexpectedEOF) got (%d, %v)", n, err)
873+
}
874+
}
875+
876+
// CopyNBufferPolicy: non-nil/non-EOF error passthrough.
877+
func TestCopyNBufferPolicy_ErrorPassthrough(t *testing.T) {
878+
readErr := errors.New("read-fail")
879+
src := &dataThenErrReader{data: []byte("x"), err: readErr}
880+
var dst bytes.Buffer
881+
buf := make([]byte, 4)
882+
n, err := iox.CopyNBufferPolicy(&dst, src, 5, buf, iox.YieldPolicy{})
883+
if !errors.Is(err, readErr) || n != 1 {
884+
t.Fatalf("want (1, read-fail) got (%d, %v)", n, err)
885+
}
886+
}
887+
888+
// wrappedWBWriter returns a wrapped ErrWouldBlock (passes IsSemantic but not ==).
889+
type wrappedWBWriter struct {
890+
n int
891+
}
892+
893+
func (w *wrappedWBWriter) Write(p []byte) (int, error) {
894+
// Partial write with wrapped semantic error.
895+
n := w.n
896+
if n > len(p) {
897+
n = len(p)
898+
}
899+
return n, fmt.Errorf("wrapped: %w", iox.ErrWouldBlock)
900+
}
901+
902+
// seekableReader wraps bytes.Reader but hides WriterTo so the slow path is used.
903+
type seekableReader struct {
904+
r *bytes.Reader
905+
}
906+
907+
func (s *seekableReader) Read(p []byte) (int, error) { return s.r.Read(p) }
908+
func (s *seekableReader) Seek(offset int64, whence int) (int64, error) {
909+
return s.r.Seek(offset, whence)
910+
}
911+
912+
// copyBufferPolicy: wrapped semantic write error triggers IsSemantic rollback.
913+
func TestCopyPolicy_SlowPath_WrappedSemanticWrite_Rollback(t *testing.T) {
914+
src := &seekableReader{r: bytes.NewReader([]byte("abcd"))}
915+
dst := &wrappedWBWriter{n: 1}
916+
pol := &recPolicy{}
917+
n, err := iox.CopyPolicy(dst, src, pol)
918+
if !iox.IsWouldBlock(err) || n != 1 {
919+
t.Fatalf("want (1, wrapped-WouldBlock) got (%d, %v)", n, err)
920+
}
921+
// Verify src was rolled back: position should be at 1 (1 written, rolled back 3).
922+
pos, _ := src.Seek(0, io.SeekCurrent)
923+
if pos != 1 {
924+
t.Fatalf("want src position 1 after rollback, got %d", pos)
925+
}
926+
}

0 commit comments

Comments
 (0)