apple / apple/swift-async-algorithms

Crash: `os_unfair_lock` unlocked from wrong thread in `MergeStorage`

Open
#430 5 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.