apple / apple/swift-async-algorithms

`debounce` drops an upstream error when no demand is outstanding

Open Beginner friendly
#450 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

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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.