Skip to content

Crash: os_unfair_lock unlocked from wrong thread in MergeStorage #430

Description

@Arideno

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 #341merge 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions