Skip to content

Fix crash: ConcurrentQueue mutates without a barrier - #38

Merged
cristipufu merged 1 commit into
aptabase:mainfrom
Kevin-4Spaces:fix/concurrent-queue-mutation-barrier
Oct 16, 2026
Merged

cristipufu merged 1 commit into
aptabase:mainfrom
Kevin-4Spaces:fix/concurrent-queue-mutation-barrier

Conversation

@Kevin-4Spaces

Copy link
Copy Markdown

The crash

Swift/Array.swift:1845: Fatal error: Array replace: subrange extends past the end

reached from AptabaseClient timer flush → EventDispatcher.flush() → ConcurrentQueue.dequeue(count:).

Cause

ConcurrentQueue's queue is created with attributes: .concurrent, so a plain sync grants shared access — it does not serialize. enqueue(_:) and enqueue(contentsOf:) correctly use async(flags: .barrier), but dequeue() and dequeue(count:) mutate elements inside a plain sync:

queue.sync {
    for _ in 0 ..< min(count, self.elements.count) {
        dequeuedElements.append(self.elements.removeFirst())
    }
}

Two concurrent callers each evaluate self.elements.count, then both removeFirst(), 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). isEmpty and count keep the plain sync — 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() and dequeue(count:).

Against the unpatched implementation both crash:

error: Exited with unexpected signal code 11

With the barrier, the full suite passes (11 tests, 0 failures).

`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.
@mrosberghaus

Copy link
Copy Markdown
Contributor

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 Aptabase.shared.flush() launch overlapping dispatcher tasks. Using the exact 0.3.12 ConcurrentQueue implementation with concurrent dequeues crashed with UnsafeMutablePointer.initialize overlapping range. We also reproduced a crash through EventDispatcher.flush() with a local mock HTTP session returning 200, so this did not require live analytics traffic.

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 NSApplication.didBecomeActiveNotification, so the current SDK's delivery timer never starts there unless activation is simulated or events are manually flushed. We plan to propose starting normal polling during initialization, while preserving the SDK's sessions, batching and transport.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants