apple / apple/swift-async-algorithms
`debounce` drops an upstream error when no demand is outstanding
- Dominant language
- Swift
- Stars
- 3.7k
- Forks
- 226
- Avg merge
- 10d 3h
- Merged PRs (30d)
- 1
Description
If the base sequence throws while the consumer is not suspended in `next()`, the error is discarded and the next `next()` returns `nil`. The consumer sees a normal completion.
## Reproduction
```swift
struct Boom: Error {}
let stream = AsyncThrowingStream { continuation in
continuation.yield(1)
Task {
try? await Task.sleep(for: .milliseconds(100))
continuation.finish(throwing: Boom())
}
}
var iterator = stream.debounce(for: .milliseconds(10)).makeAsyncIterator()
_ = try await iterator.next() // 1
try await Task.sleep(for: .milliseconds(300)) // consumer busy; upstream throws meanwhile
try await iterator.next() // nil; expected: throws Boom
```
Without the sleep, so the consumer is waiting in `next()` when the upstream throws, `Boom` is thrown.
Validation diagram form. Value at tick 1, failure at tick 2, consumer away until tick 5:
```swift
validate {
"a^"
$0.inputs[0].debounce(for: .steps(0), clock: $0.clock)
"a,,,^" // actual: "a,,,|"
}
```
The minimal form, `"a^"` with `.steps(1)` and a continuous consumer, gives `"-[a|]"` where `"-[a^]"` is expected; there the deadline and the failure share a tick, so the gap form above is the clearer one.
The package's Debounce guide states the operator "throws when the base type throws".
## Cause
`DebounceStateMachine.upstreamThrew(_:)`, case `.waitingForDemand(task, .none, clockContinuation, .none)`:
```swift
self.state = .finished
return .cancelTaskAndClockContinuation(task: task, clockContinuation: clockContinuation)
```
The error is not stored. `.upstreamFailure(error)` exists for this case and `next()` already handles it (`resumeDownstreamContinuationWithError`), but nothing in the file ever assigns that state; this branch copies the normal-finish transition instead. `elementProduced` in the same state buffers the element, so a value arriving here survives and an error does not.
## Fix
Transition to `.upstreamFailure(error)` in that branch and keep cancelling the clock continuation. The following `next()` then throws.
## Related
#269 had a different cause (a Swift 5.8 change in `group.waitForAll`, fixed by #254 before 1.0) and a different symptom (a stall).
Found with hegel-swift (property-based laws over generated validation-diagram scripts); reproduction reduced by hand to the above. Environment: swift-async-algorithms 1.1.5, Xcode 26 / Swift 6.3.3, macOS 26.
Contributor guide
Research direction
Start with DebounceStateMachine.upstreamThrew(_:) in the .waitingForDemand(task, .none, clockContinuation, .none) case and compare it with the existing .upstreamFailure(error) handling in next(). Add regression coverage for the provided delayed-consumer reproduction or validation diagram, and verify that the later next() throws Boom instead of returning nil.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- api
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 82/100