apple / apple/swift-async-algorithms
Crash: `os_unfair_lock` unlocked from wrong thread in `MergeStorage`
- Dominant language
- Swift
- Stars
- 3.7k
- Forks
- 226
- Avg merge
- 10d 3h
- Merged PRs (30d)
- 1
Description
We hit an intermittent crash in production using `chunks(ofCount:or:)` on an `AsyncStream`. It comes down to the `os_unfair_lock` inside `MergeStorage` being locked on one cooperative-pool thread and then unlocked on another, which `os_unfair_lock` doesn't allow:
```
EXC_BREAKPOINT
_os_unfair_lock_unowned_abort
```
`chunks(ofCount:or:)` builds a `merge` of the stream and an `AsyncTimerSequence` under the hood, so this is really a `merge` issue. It looks like the same root cause as #341 — `merge` resumes its continuation from an unstructured task without carrying the executor, so the resume (and the unlock) lands on a different thread than the one that took the lock.
## Stack trace
```
Thread 48, queue = 'com.apple.root.user-initiated-qos.cooperative'
stop reason = EXC_BREAKPOINT
#0 _os_unfair_lock_unowned_abort libsystem_platform.dylib
#1 _os_unfair_lock_unlock_slow libsystem_platform.dylib
#2 static Lock.unlock(_:) Locking.swift:103
#3 Lock.unlock() Locking.swift:136
#4 closure #2 in closure #1 in MergeStorage.next(continuation:) MergeStorage.swift:109
#7 closure #1 in MergeStorage.next() MergeStorage.swift:107
#9 withTaskCancellationHandler(operation:onCancel:isolation:) libswift_Concurrency
#11 MergeStorage.next() MergeStorage.swift:65
#12 AsyncMerge2Sequence.Iterator.InternalClass.next() AsyncMerge2Sequence.swift:92
#13 AsyncMerge2Sequence.Iterator.next() AsyncMerge2Sequence.swift:103
#14 AsyncChunksOfCountOrSignalSequence.Iterator.next() AsyncChunksOfCountOrSignalSequence.swift:129
#16 AsyncIteratorProtocol.next(isolation:) libswift_Concurrency
#17 for await batch in stream.chunks(ofCount: 64, or: .repeating(every: .seconds(30)))
```
The lock is taken in `MergeStorage.next()` (frame #11) and released in the continuation-resume closure (frame #4), which runs on a different thread.
## Repro
A producer task yields into an `AsyncStream` while a consumer drains it through `chunks(ofCount:or:)`. The count-vs-timer merge keeps both branches hot, so the lock acquire and release frequently land on different threads. It's a timing race, so it doesn't fail on the first iteration — running several concurrently makes it show up reliably, usually within seconds to a minute.
```swift
import AsyncAlgorithms
func reproduce() async {
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
let producer = Task.detached {
for i in 0..<1_000_000 {
continuation.yield(i)
}
continuation.finish()
}
for await batch in stream.chunks(ofCount: 64, or: .repeating(every: .milliseconds(10))) {
_ = batch.count
}
await producer.value
}
await withTaskGroup(of: Void.self) { group in
for _ in 0..<50 {
group.addTask { await reproduce() }
}
}
```
Reproduces faster on multi-core hardware. A shorter timer interval and more concurrent children widen the race window.
## Environment
- swift-async-algorithms 1.1.4
- Swift 6.3.3 release toolchain
- iOS (device and simulator), arm64
- Default cooperative executor, no custom executor
Contributor guide
Research direction
Start with MergeStorage.next() in MergeStorage.swift, especially the lock acquisition and continuation-resume path identified in the stack trace. Compare the behavior with the related issue #341 and run the supplied concurrent chunks(ofCount:or:) reproduction. Done means the reproduction no longer triggers the os_unfair_lock wrong-thread crash.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100