Repository navigation
Fix crash: ConcurrentQueue mutates without a barrier - #38
cristipufu merged 1 commit into
Conversation
`ConcurrentQueue` runs on a concurrent DispatchQueue, so a plain `sync`
grants shared (reader) access, not exclusive access. `enqueue` correctly
uses `async(flags: .barrier)`, but both `dequeue()` and `dequeue(count:)`
MUTATE `elements` inside a plain `sync` — so two callers can each read a
non-empty array and then `removeFirst()` past each other's end.
In production this crashes as:
Swift/Array.swift:1845: Fatal error: Array replace: subrange extends past the end
reached from `EventDispatcher.flush()` → `ConcurrentQueue.dequeue(count:)`.
`flush()` is driven by a repeating timer and also by app lifecycle events,
so overlapping flushes happen on real devices.
Fix: give both dequeues `sync(flags: .barrier)`. The read-only `isEmpty`
and `count` keep the plain `sync` — concurrent reads are the point of the
concurrent queue.
Adds two regression tests that drain the queue from 8 concurrent workers
and assert every element comes out exactly once. Both crash with SIGSEGV
against the unpatched implementation and pass with the barrier.
|
We reproduced the same issue while integrating Aptabase Swift 0.3.12 into Wisp's macOS app and File Provider extension. Concurrent calls to the public After cherry-picking this PR's patch into our test branch, both concurrent dequeue regression tests pass. Thanks for the fix; the barrier is needed for the app-lifecycle/manual-flush overlap too. We are also testing a separate lifecycle fix: macOS File Provider extensions do not receive |
The crash
reached from
AptabaseClienttimer flush →EventDispatcher.flush()→ConcurrentQueue.dequeue(count:).Cause
ConcurrentQueue'squeueis created withattributes: .concurrent, so a plainsyncgrants shared access — it does not serialize.enqueue(_:)andenqueue(contentsOf:)correctly useasync(flags: .barrier), butdequeue()anddequeue(count:)mutateelementsinside a plainsync:Two concurrent callers each evaluate
self.elements.count, then bothremoveFirst(), and the second removes past the end.This is reachable in normal use:
flush()runs from the repeating timer and from app lifecycle handlers, so two flushes can overlap on a real device.Fix
Give both dequeues
sync(flags: .barrier).isEmptyandcountkeep the plainsync— concurrent reads are the reason for the concurrent queue.Tests
Two regression tests drain a 2,000-element queue from 8 concurrent workers and assert every element is dequeued exactly once, covering both
dequeue()anddequeue(count:).Against the unpatched implementation both crash:
With the barrier, the full suite passes (11 tests, 0 failures).