apple / apple/swift-async-algorithms
flatMapLatest: crash and intermittent hang under a single consumer
- Dominant language
- Swift
- Stars
- 3.7k
- Forks
- 226
- Avg merge
- 10d 3h
- Merged PRs (30d)
- 1
Description
### Environment
swift-async-algorithms 1.1.4 (commit d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440)
Swift 6.3.2 (swiftlang-6.3.2.1.108, clang-2100.1.1.101), swift-driver 1.148.6
Reproduced on iOS 18.4-26.2 Simulator (arm64), Xcode 26.5 (build 17F42), Swift language mode v6
### Summary
Consuming a flatMapLatest sequence with a single for await loop, where the base switches inner sequences frequently, can trap in `FlatMapLatestStateMachine.next(for:)`:
`FlatMapLatestStateMachine.swift:132: Precondition failed: Already have downstream continuation`
The same scenario also intermittently hangs (the downstream continuation is never resumed) instead of trapping.
In `FlatMapLatestStorage.next()`, the `.suspend` case releases the lock then `suspend()` re-acquires it to call `next(for:)`:
https://github.com/apple/swift-async-algorithms/blob/1.1.4/Sources/AsyncAlgorithms/FlatMapLatest/FlatMapLatestStorage.swift#L53-L68
`next(for:)` then asserts the state is unchanged:
https://github.com/apple/swift-async-algorithms/blob/1.1.4/Sources/AsyncAlgorithms/FlatMapLatest/FlatMapLatestStateMachine.swift#L132-L133
```Swift
precondition(downstreamCont == nil, "Already have downstream continuation")
precondition(buffer.isEmpty, "Buffer should be empty if suspending")
```
The operator's internal outer/inner tasks take the same lock and run concurrently with the consumer, so they can mutate the state during the window between `next()` releasing the lock and `next(for:)` re-acquiring it.
### Prod Stack trace
```
Crashed: com.apple.root.utility-qos.cooperative
EXC_BREAKPOINT
0 FlatMapLatestStateMachine.next(for:)
1 closure #1 in closure #1 in FlatMapLatestStorage.suspend()
2 partial apply for closure #1 in closure #1 in FlatMapLatestStorage.suspend()
3 Lock.withLock(_:)
4 closure #1 in FlatMapLatestStorage.suspend()
5 partial apply for closure #1 in FlatMapLatestStorage.suspend()
6 withUnsafeThrowingContinuation(isolation:_:)
7 swift::runJobInEstablishedExecutorContext(swift::Job*)
```
### Reproduction
```Swift
for _ in 0 ..< 5000 {
let toggles = AsyncStream { continuation in
for index in 0 ..< 20 {
continuation.yield(index.isMultiple(of: 2))
}
continuation.finish()
}
let sequence = toggles.flatMapLatest { enabled in
AsyncStream { continuation in
if enabled {
for value in 0 ..< 200 {
continuation.yield(value)
}
}
continuation.finish()
}
}
for await _ in sequence {}
}
```
Contributor guide
Research direction
Start by running the single-consumer reproduction from the issue against FlatMapLatestStorage.swift and FlatMapLatestStateMachine.swift. Trace the lock and continuation transitions between next(), suspend(), and next(for:), then add regression coverage showing that rapid outer-sequence switching completes without trapping or hanging.
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
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 56/100