apple / apple/swift-async-algorithms

flatMapLatest: crash and intermittent hang under a single consumer

Open
#437 1 comment 0 reactions 0 assignees View on GitHub
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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.