Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
973d999
test(b2): reproduce writes after copy cancellation
ffumero2003 Oct 29, 2026
809a1fb
fix(b2): stop a cancelled copy from writing after copyContext returns
ffumero2003 Oct 29, 2026
73a3ae8
test(b2): match the abandoned-copy error with errors.Is and document …
ffumero2003 Oct 29, 2026
6672425
Merge remote-tracking branch 'origin/main' into fix-b2-issue-229-copy…
ffumero2003 Oct 29, 2026
cd459b6
docs(b2): state exactly what ReadFrom guarantees after a cancelled copy
ffumero2003 Oct 29, 2026
6212556
Merge remote-tracking branch 'origin/main' into fix-b2-issue-229-copy…
ffumero2003 Oct 29, 2026
8db210f
Merge remote-tracking branch 'origin/main' into fix-b2-issue-229-copy…
ffumero2003 Oct 30, 2026
a37b5fa
test(b2): pin ReadFrom cancellation during blocked error cleanup
ffumero2003 Oct 30, 2026
42b69e2
fix(b2): release the copy guard before upload error cleanup
ffumero2003 Oct 30, 2026
ed73f24
test(b2): pin error mutex release and Close cleanup waiting
ffumero2003 Oct 30, 2026
af786d6
docs(b2): document Writer.Close callback wait
ffumero2003 Oct 30, 2026
6803b3d
test(b2): make Writer.Close callback proof deterministic
ffumero2003 Oct 30, 2026
8071677
test(b2): pin Writer.ReadFrom upload error precedence
ffumero2003 Oct 30, 2026
93ef107
fix(b2): preserve upload errors from Writer.ReadFrom
ffumero2003 Oct 30, 2026
3a2d7fa
test(b2): pin ReadFrom caller cancellation with recorded transport er…
ffumero2003 Oct 30, 2026
906f795
fix(b2): preserve caller cancellation cause in Writer.ReadFrom
ffumero2003 Oct 30, 2026
cad0e6d
test(b2): pin caller custom causes in Writer.ReadFrom
ffumero2003 Oct 30, 2026
9652b15
fix(b2): distinguish Writer cancellation from caller causes
ffumero2003 Oct 30, 2026
8c13b6d
test(b2): start ReadFrom deadline after upload setup
ffumero2003 Oct 30, 2026
34bb7ac
docs(b2): clarify Writer ReadFrom error identity
ffumero2003 Oct 30, 2026
3f4e6b5
Merge remote-tracking branch 'origin/main' into fix-b2-issue-229-copy…
ffumero2003 Oct 30, 2026
dee1563
test(b2): bound copy cancellation and Close cleanup waits
ffumero2003 Oct 30, 2026
84b17e2
docs(b2): clarify cancellation and breaking Close wait contract
ffumero2003 Oct 30, 2026
5f1c876
test(b2): acknowledge cancellation stub body close results
ffumero2003 Oct 30, 2026
8ac320e
Merge remote-tracking branch 'origin/main' into fix-b2-issue-229-copy…
ffumero2003 Oct 30, 2026
e7104cb
Merge remote-tracking branch 'origin/fix-b2-issue-229-copycontext-str…
ffumero2003 Oct 30, 2026
61827da
Merge remote-tracking branch 'origin/main' into fix-b2-issue-245-read…
ffumero2003 Oct 30, 2026
d74ee24
Merge remote-tracking branch 'origin/main' into fix-b2-issue-245-read…
ffumero2003 Oct 30, 2026
9d869bb
test(b2): inline the folder-delete test setup removed by #247
ffumero2003 Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Fixed-20261030-130448.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Fixed
body: Writer.ReadFrom and io.Copy now return the recorded large-file upload error instead of masking it with context cancellation (#245).
time: 2026-10-30T13:04:48.509659-06:00
2 changes: 1 addition & 1 deletion b2/b2.go
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ func (o *Object) URL() string {
//
// Callers must close the writer when finished and check the error status.
func (o *Object) NewWriter(ctx context.Context, opts ...WriterOption) *Writer {
ctx, cancel := context.WithCancel(ctx)
ctx, cancel := context.WithCancelCause(ctx)
w := &Writer{
o: o,
name: o.name,
Expand Down
20 changes: 11 additions & 9 deletions b2/b2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"bytes"
"context"
"crypto/sha1"
"errors"
"fmt"
"io"
"io/ioutil"
Expand Down Expand Up @@ -1726,6 +1727,7 @@ func TestWriterReturnsError(t *testing.T) {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
want := testError{}

client := &Client{
backend: &beRoot{
Expand All @@ -1734,13 +1736,13 @@ func TestWriterReturnsError(t *testing.T) {
errs: &errCont{
errMap: map[string]map[int]error{
"uploadPart": {
0: testError{},
1: testError{},
2: testError{},
3: testError{},
4: testError{},
5: testError{},
6: testError{},
0: want,
1: want,
2: want,
3: want,
4: want,
5: want,
6: want,
},
},
},
Expand All @@ -1756,8 +1758,8 @@ func TestWriterReturnsError(t *testing.T) {
r := io.LimitReader(zReader{}, 1e7)
w.ChunkSize = 1e4
w.ConcurrentUploads = 4
if _, err := io.Copy(w, r); err == nil {
t.Fatalf("io.Copy: should have returned an error")
if _, err := io.Copy(w, r); !errors.Is(err, want) {
t.Fatalf("io.Copy error = %v, want %v", err, want)
}
}

Expand Down
3 changes: 2 additions & 1 deletion b2/core_edges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func TestObjectLockUpdateInvalidatesCachedInfo(t *testing.T) {

func TestObjectDeleteRejectsFolderStatus(t *testing.T) {
file := &testFile{n: "folder", a: "folder", files: map[string]string{"folder": "present"}}
object := objectForDeleteOptionTest(file)
root := &beRoot{b2i: &testRoot{errs: &errCont{}, bucketMap: make(map[string]map[string]string)}}
object := &Object{f: &beFile{b2file: file, ri: root}}
if err := object.Delete(context.Background()); err == nil || !strings.Contains(err.Error(), "not a regular file or hide marker") {
t.Fatalf("Delete(folder) error = %v, want non-regular-file rejection", err)
}
Expand Down
4 changes: 2 additions & 2 deletions b2/progress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@ func (progressSkipChunk) uploadPart(context.Context, readResetter, string, int,
}

func TestProgressResumeSkipDoesNotReadClosedBuffer(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
buf := newMemoryBuffer()
if _, err := buf.Write([]byte("already uploaded")); err != nil {
t.Fatal(err)
Expand Down
37 changes: 28 additions & 9 deletions b2/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ type Writer struct {

csize int
ctx context.Context
cancel context.CancelFunc // cancels ctx
cancel context.CancelCauseFunc // cancels ctx with the first Writer error
ctxf func() context.Context
errf func(error)
ready chan chunk
Expand Down Expand Up @@ -140,6 +140,11 @@ type chunk struct {
buf writeBuffer
}

type writerCause struct{ err error }

func (c writerCause) Error() string { return c.err.Error() }
func (c writerCause) Unwrap() error { return c.err }

func (w *Writer) setErr(err error) {
if err == nil || err == io.EOF {
return
Expand All @@ -151,7 +156,7 @@ func (w *Writer) setErr(err error) {
}
blog.V(1).Infof("error writing object: %T", err)
w.err = err
w.cancel()
w.cancel(writerCause{err})
w.emux.Unlock()
if w.ctxf == nil || w.file == nil {
return
Expand Down Expand Up @@ -655,16 +660,30 @@ func (w *Writer) sendChunk() error {
//
// Note that io.Copy will automatically choose to use ReadFrom.
//
// If the Writer's context is cancelled while ReadFrom is copying from a
// non-seekable r, ReadFrom returns the context error without waiting for a
// Read on r that is already in progress. Once ReadFrom has returned, nothing
// more from r is written to w; data read before the cancellation may already
// have been written. That pending Read may still finish later, so do not reuse
// r until it has returned (for example, close r first if it supports it).
// On every path, ReadFrom returns the Writer's error when it cancels itself;
// caller cancellation returns context.Canceled or context.DeadlineExceeded,
// even if the caller supplied a custom cause. If the Writer's context is
// cancelled while copying from a non-seekable r, ReadFrom returns without
// waiting for a Read on r that is already in progress.
// When the Writer cancels itself, ReadFrom returns the same first recorded
// error as Close, so errors.Is and errors.As work and io.Copy sees it rather
// than context.Canceled.
// Once ReadFrom has returned, nothing more from r is written to w; data read
// before cancellation may already have been written. That pending Read may
// still finish later, so do not reuse r until it has returned (for example,
// close r first if it supports it).
//
// With Resume, ReadFrom uses the ordinary buffered path; if r is seekable,
// its remaining length supplies the progress total.
func (w *Writer) ReadFrom(r io.Reader) (int64, error) {
func (w *Writer) ReadFrom(r io.Reader) (n int64, err error) {
defer func() {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
var cause writerCause
if errors.As(context.Cause(w.ctx), &cause) {
err = cause.err
}
}
}()
rs, ok := r.(io.ReadSeeker)
if !ok {
return copyContext(w.ctx, w, r)
Expand Down
2 changes: 1 addition & 1 deletion b2/writer_log_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestWriterErrorLoggingOmitsErrorText(t *testing.T) {
}
var output bytes.Buffer
log.SetOutput(&output)
w := &Writer{name: "object", cancel: func() {}}
w := &Writer{name: "object", cancel: func(error) {}}
w.setErr(errors.New("server-error-secret"))
if strings.Contains(output.String(), "server-error-secret") {
t.Fatal("writer logged untrusted error text")
Expand Down
Loading
Loading