Snapshot Queue.All under the lock instead of holding RLock across the yield callback#624
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates Queue.All to honor its documented “consistent snapshot at iteration start” contract by copying the queue’s contents under RLock, then releasing the lock before invoking the caller’s yield callback—eliminating lock retention across arbitrary user code and preventing writer starvation / lock-order deadlocks.
Changes:
- Change
Queue.Allto snapshotq.itemsunderRLockand iterate the snapshot after unlocking. - Add a regression test that mutates the same queue inside
for v := range q.All()and asserts no deadlock and stable snapshot values.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| internal/concurrent/queue.go | Implements snapshot iteration for Queue.All by copying under RLock and yielding after unlocking. |
| internal/concurrent/queue_test.go | Adds a timeout-based regression test ensuring All allows reentrant mutation in the loop body and yields only the original snapshot. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ce18cd4 to
03d8ba3
Compare
Queue.All held the read lock for the entire iteration and ran each yield(item) (arbitrary caller code) while the lock was live. Since sync.RWMutex is non-reentrant, any loop body that called Enqueue, Dequeue, or Len on the same queue deadlocked, and a slow consumer blocked all writers. This contradicted the documented contract that All is a consistent snapshot taken when iteration starts. Take the snapshot under the lock, release it, then yield the copied items.
03d8ba3 to
53c081c
Compare
Go API Parity ReviewResult: ✅ Out of scope — no parity issues This PR only modifies Since no exported Go APIs or user-visible behaviors are changed, this PR is outside the scope of cross-repo consistency review. No comparison with the upstream .NET or Python implementations is necessary. Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
What
Queue.Allininternal/concurrent/queue.gopreviously heldq.mu.RLockfor the entire iteration and invoked eachyield(item)(arbitrary caller code) while the read lock was still held. This change takes a snapshot of the items under the lock, releases the lock, and then yields the copied values.Why
The method's doc states that
All"corresponds to a consistent snapshot of the Queue's contents at the time iteration starts", but the implementation took no copy and kept the lock live across the callback. Becausesync.RWMutexis non-reentrant:Enqueue,Dequeue, orLenon the same queue deadlocks.Snapshotting under the lock and yielding afterwards matches the documented moment-in-time semantics and removes both hazards. This aligns with the enumeration semantics used across the .NET and Python SDKs, where enumerating a concurrent collection iterates a point-in-time view rather than pinning the collection's lock across user code. The sibling
Map.Allexplicitly documents that it is not a snapshot;Queue.Alldocuments that it is, so the implementation should honor that contract.Tests
Added
TestQueue_AllSnapshotAllowsMutationInLoopin the canonicalqueue_test.go. It enqueues items, then callsEnqueue/Dequeueon the same queue from insidefor v := range q.All(), asserting under a timeout that iteration completes and yields exactly the original snapshot values. The test deadlocks (times out) against the previous code and passes after the fix. Verified withgo test -race ./internal/concurrent/....