Repository navigation
fix(b2): stop a cancelled copy from writing after copyContext returns - #236
Conversation
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.
…context-stray-write
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.
…context-stray-write
There was a problem hiding this comment.
🟡 Changes recommended
Holding the mutex across Writer.Write can deadlock cancellation and ReadFrom when a synchronous error callback waits for ReadFrom to return.
1 open issue
What changed in this PR
Addresses #229 by preventing a cancelled copy from writing to the B2 Writer after ReadFrom returns, without waiting for a blocked source read.
Changes:
- Guards destination writes and stops forwarding them on cancellation.
- Adds channel-controlled regression tests for delayed writes, including after
Writer.Close. - Documents pending-read behavior and adds a Fixed changelog entry.
Review: 1 Important, 1 Nit, 0 Pre-existing. Checked the supplied changes against #229 and inspected Writer error-handling paths. Tests were not run during this review.
| File | Description |
|---|---|
b2/writer.go |
Documents cancellation behavior; no executable code changes. |
b2/reader.go |
Adds synchronized protection against writes after cancellation returns. |
b2/copy_context_test.go |
Tests delayed-write rejection after cancellation and close. |
.changes/unreleased/Fixed-20261029-145247.yaml |
Records the cancellation fix for users. |
Review effort: Balanced
david-santacruz-backblaze
left a comment
There was a problem hiding this comment.
Let's give a review for the copilot finding
…context-stray-write
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.
|
@david-santacruz-backblaze The Copilot finding is fixed and the branch is synced with main. Summary:
|
david-santacruz-backblaze
left a comment
There was a problem hiding this comment.
Please mate, give a check of these issues:
Definitely review (verify each, then fix it or reply with a reason)
- Changelog
Changed-20261030-113335.yaml.Writer.Closenow waits for the cancel-on-error callback. Anerrfthat calls or waits onClosedeadlocks. Mark the entry**Breaking:**and tell callers not to call or wait onClosefromerrf(AGENTS.md rule 13). Closehang risk.defer w.wg.Wait()runs on every exit path. A worker stuck inerrforb2_cancel_large_filenow blocksCloseindefinitely. Show that this is bounded, or bound it, and add a test.- PR description disagrees with the diff (rule 10). It says "
b2/reader.goonly" and "one Fixed entry". The diff also changeswriter.go(setErr, thewrite(p, setErr)split, theClosewait and godoc), addscopy_context_cleanup_test.goand adds a second fragment. Update the description to match the final diff and explain why theClosechange is needed.
Suggestions: worth addressing
- The guard is held across
Writer.write, including I/O, andcopyContextcancellation blocks on it. This is safe today only becausesendChunkselects onctx.Done. WithUseFileBufferorgetLargeFile, cancel would wait, which the PR says it avoids. Prove it can't happen, or test it. setErrcleanup (errf, network cancel) still runs undercloseWrite.RLock. Confirm this is intended and covered.- The
Fixedfragment omits the newReadFromcontract. It returns the context error without waiting, and aReadmay finish later. - Verify
go test -race -count=20 -shuffle=on ./b2and paste the result.
Suggestions: optional
TestWriterCloseWaitsForErrorCallbackusestime.After(200ms)as a negative assertion. Prefer a signal, perdocs/review-lessons.md.- Comment why
guardedCopyWriteranderrCopyAbandonedexist. - Share the setup between the two new tests (
b2wiretestserver, release channels, callback gating). - Rename
setErr func(error)to say what it does, for exampleonTerminalErr. It is also used by only one caller. guardedCopyWritertype-asserts*Writerand calls its private methods. Consider a narrower seam.- Fold the two
closer == nilbranches incopyContextWithCloserinto one. - Add
Write-after-failed-Closecoverage, or note that it's out of scope (#181).
Lock order (closeWrite.RLock, then guard mu, then emux) looked consistent, and the tests use channels. Both are fine.
|
@david-santacruz-backblaze Thanks, all addressed (dee1563, 84b17e2, 5f1c876), and the branch is synced with main: Definitely review
Suggestions
Every earlier break still fails, and the full verification table is in the description. |

Problem
CI on #205 failed once with a panic in production code, on one of six test jobs (Go 1.27.3,
windows-latest,-race -shuffle=on):It is timing-dependent, so any PR's CI can hit it.
Root cause
Writer.ReadFromwith a non-seekable source callscopyContext(w.ctx, w, r).copyContextWithCloserrunsio.Copyin a goroutine, and on cancellation without a closer (the Writer path) it returnedctx.Err()without waiting for that goroutine. The goroutine could still callWriter.WriteafterReadFromreturned and the caller closed the Writer; by then the buffer was released andp[:left]panicked. Waiting for the goroutine is not an option, because it can be blocked inr.Readindefinitely.Changes
b2/reader.go: a write guard.guardedCopyWriter. It takes a mutex, returnserrCopyAbandonedonce the copy has stopped, and otherwise forwards to the real writer.copyContextWithClosersetsstoppedunder that lock before returning. After it returns, nothing more fromrreaches the destination. It never waits for a blockedRead.Writealready in progress. ThatWritecan include network calls (sendChunk→getLargeFile→b2_start_large_file, and Resume listings), but they run underw.ctx, the same context being cancelled, so they return once it ends. Only aRoundTripperthat ignores the request context could hold the guard longer, which would affect every API call. This is stated in a comment atguardedCopyWriter.Writeand tested.b2/writer.go: no deadlock between cancellation and error cleanup. Copilot found that holding the guard acrossWriter.Writemade cancellation wait forsetErr's cleanup (b2_cancel_large_fileand theWithCancelOnErrorcallback), which could deadlock:Writer.Writeis split intowrite(p, setErr), so the guard is released on terminal write errors, before cleanup runs.setErrreleasesemuxbefore cleanup, so a producer ingetErrdoesn't wait behind a worker's cleanup.Writer.writeholdscloseWrite.RLock. That is intended:ClosetakescloseWrite.Lockand waits for that cleanup anyway (below).Writer.Closenow waits for worker cleanup: a breaking change. OncesetErrno longer holdsemuxthrough cleanup,Closecould return before a worker'sb2_cancel_large_fileanderrffinished, so callers would stop receiving the cleanup result beforeClosereturned.Closenow waits for the workers (defer w.wg.Wait()) on every exit path.ctxf, so a deadline there bounds the wait (tested).errfis caller code and must return promptly.errfthat callsClose, or waits on the goroutine callingClose, deadlocks.Writer.CloseandWithCancelOnError, and in a Breaking changelog entry.Changelog:
Fixed: theReadFromcontract for non-seekable readers;Changed(Breaking):Closewaits for the cancel-on-error callback, with the deadlock rule and how to bound the wait.Tests:
b2/copy_context_test.go(#229) andb2/copy_context_cleanup_test.go.Verification
Each break was applied alone; the named test failed, then passed after revert:
destination Write calls after copyContext returned = 1, want 0ReadFrom waited for blocked cancel_large_file after cancellationemuxReadFrom waited for blocked cancel_large_file after cancellation(worker failure while producer is ingetErr)Close's worker wait removedClose returned while errf was blocked(50 of 50 runs)ctxfcontextClose waited past the ctxf deadline for cancel_large_fileb2_start_large_fileignores its context (memory and file buffers)ReadFrom waited for start_large_file after cancellationWith no B2 credentials set:
go test -race -count=30 -run 'Cancellation|DelayedWrite|WriteAfterClose|CloseWaits|ReadFrom' ./b2,go test -race -count=5 -shuffle=on ./...,go vet ./...andmake cipass. The SDK panel reviewed the fix; its findings (the undocumentedClosecontract, an unreliable callback test) are addressed.#252 (#245) is stacked on this branch and is retargeted to
mainafter this merges.Closes #229