Skip to content

fix(b2): return the upload error from Writer.ReadFrom instead of context canceled - #252

Merged
ffumero2003 merged 29 commits into
mainfrom
fix-b2-issue-245-readfrom-masks-error
Oct 30, 2026
Merged

ffumero2003 merged 29 commits into
mainfrom
fix-b2-issue-245-readfrom-masks-error

Conversation

@ffumero2003

@ffumero2003 ffumero2003 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When a large-file upload fails permanently, io.Copy(w, r) into a *b2.Writer returned context canceled instead of the upload error. io.Copy uses Writer.ReadFrom, which copies under the Writer's context. A part failure calls Writer.setErr, which records the error and cancels that context; copyContext then saw ctx.Done() and returned ctx.Err(), masking the real failure. Close() returned the right error, but most callers check io.Copy's.

Root cause

The Writer couldn't tell "the Writer cancelled itself because an upload failed" from "the caller cancelled". A first fix that compared error types was wrong: base.makeNetRequest flattens most transport errors (a dial or I/O error caused by the caller's cancellation) into a plain b2err, so a caller's cancellation could come back as an opaque transport error.

Fix

  • The Writer's context is created with context.WithCancelCause (b2/b2.go).
  • setErr cancels it with the first recorded error, wrapped in an unexported writerCause tag.
  • ReadFrom replaces a context error only when context.Cause unwraps to a writerCause, and returns the original upload error (so errors.Is / errors.As match it, as they do for Close). Anything else, including a caller's own cause from WithCancelCause / WithTimeoutCause, leaves context.Canceled / context.DeadlineExceeded unchanged.
  • ReadFrom godoc states both outcomes for every path (non-seekable, Resume, streaming).
  • Changelog: Fixed.

Verification

Tests in b2/writer_readfrom_error_test.go and the tightened TestWriterReturnsError (errors.Is on the injected error) cover:

  • upload failure on the non-seekable, Resume and streaming paths;
  • caller cancellation and deadline with a flattened transport error recorded;
  • caller cancellation and deadline with a caller-supplied cause.

The deadline case starts its timer only after the controlled part upload is reached, so it doesn't race setup.

Break Failing output
setErr cancels without the tag io.Copy error = context canceled, want the permanent upload-part failure
ReadFrom substitutes any cause io.Copy error = caller-provided cancellation cause, want caller's context canceled (and deadline exceeded)
ReadFrom always returns the recorded error io.Copy error = flattened transport error, want caller's context canceled (and deadline exceeded)
ReadFrom never substitutes io.Copy error = context canceled, want the permanent upload-part failure

With no B2 credentials set: go test -race -count=50 -run ReadFrom ./b2 passes, as do #236's cancellation tests at -race -count=30; go test -race -count=5 -shuffle=on ./... and go vet ./... pass. The SDK panel reviewed two rounds; its findings (the caller-cause leak, godoc identity, deadline-test timing) are addressed.

Also: fixes the b2 test build on main

#247 removed the objectForDeleteOptionTest helper from b2/b2_test.go, while #249's TestObjectDeleteRejectsFolderStatus (b2/core_edges_test.go) still used it, so the b2 tests stopped compiling on main once both merged. Commit 9d869bb inlines the same two setup lines TestObjectDeleteAllowsCopyStatus uses. Test-only; merging this PR makes main green again.

Merge order

#236 has merged; this PR is now based on main and synced with it.

Closes #245

Pin delayed source reads that resume after copyContext returns and Writer.ReadFrom has been cancelled and closed.

Part of #229
A cancelled copy could return while its source Read was still blocked. When that Read resumed, the copy goroutine could write to a Writer that the caller had already closed, causing the slice-bounds panic seen in CI run 40925001975.

Guard destination writes and mark the guard stopped before returning on cancellation. Waiting for the copy goroutine is not an option because the source Read may block forever. Preserve the closer path that closes the body and waits for its copy to finish.

Closes #229
…ReadFrom cancellation

Address the panel's nits on #229: the regression test compared the error
text, so wrapping errCopyAbandoned would break it while the guard still
works; and ReadFrom's godoc did not say that a Read already in progress on a
non-seekable source can outlive a cancelled call.
The comment said nothing a pending Read returns is written, but the guard
only stops writes once copyContext returns: a Write already in progress, with
data read before the cancellation, can still complete. Say what the code
guarantees: after ReadFrom returns, nothing more from r reaches the Writer.
Hold cancel_large_file or WithCancelOnError callbacks behind channels after producer and worker upload failures. ReadFrom must return on cancellation before those channels are released, and the callback must receive the cancellation request result (#229).
Keep the existing Writer error path and release the copy guard only on terminal write errors. No buffer writes follow that hook, and the stopped check still gates every later write, so no source bytes can reach the destination after a cancelled copy returns.

Release the error-state mutex before cancel_large_file and errf as well, so a producer observing a worker failure does not wait behind cleanup. Close joins its existing worker wait group on error exits to preserve delivery of the cleanup result to WithCancelOnError callers.

Reject a separate deferred error-recording and cleanup mechanism: reusing setErr preserves first-error ownership and callback semantics without adding parallel state. The public API and ReadFrom godoc remain unchanged.

Closes #229
Hold the producer inside buffer allocation until a worker enters blocked cancellation cleanup, then release it into getErr while the copy guard is still held. This makes moving cleanup back under emux fail deterministically.

Gate the worker cancellation request separately and verify that its nonblocking error callback has completed when Close returns. Removing the deferred worker wait now fails without making the callback depend on Close.

Keep the existing cancellation cases and production code unchanged.
@ffumero2003 ffumero2003 added this to the 0.10.0 milestone Oct 30, 2026
@ffumero2003 ffumero2003 added the bug Something isn't working label Oct 30, 2026
@ffumero2003
ffumero2003 requested a review from goanpeca as a code owner October 30, 2026 20:40
@ffumero2003 ffumero2003 self-assigned this Oct 30, 2026
…ay-write' into fix-b2-issue-245-readfrom-masks-error
Base automatically changed from fix-b2-issue-229-copycontext-stray-write to main October 30, 2026 20:58
…from-masks-error

# Conflicts:
#	b2/writer.go
#247 removed objectForDeleteOptionTest from b2_test.go while #249's
TestObjectDeleteRejectsFolderStatus still used it, so the b2 tests no
longer compile on main. Use the same setup as TestObjectDeleteAllowsCopyStatus.
@ffumero2003
ffumero2003 merged commit c652e37 into main Oct 30, 2026
22 of 23 checks passed
@ffumero2003
ffumero2003 deleted the fix-b2-issue-245-readfrom-masks-error branch October 30, 2026 22:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(b2): Writer.ReadFrom returns context.Canceled instead of the upload error

2 participants