The problem is the use of a `nonisolated` function to initiate an asynchronous update of an actor-isolated property. (I'm surprised the compiler even permits that.) Not only is it misleading, but actors also feature [reentrancy][1], and you introduce all sorts of unintended races.
However, you can add an actor-isolated function to `Limiter`:
```swift
func submit(task: @escaping () async -> Void) {
switch policy {
case .throttle: throttle(task: task)
case .debounce: debounce(task: task)
}
}
```
Note, I am not using `callAsFunction` as an actor-isolated function as it looks like (in Xcode 13.2.1, for me, at least) that this causes a segmentation fault in the compiler.
Anyway, you can then modify your tests to use the `submit` actor-isolated function, e.g.:
```swift
// test throttling as user enters “hello, world” into a text field
func testThrottler() async throws {
// Given
let promise = expectation(description: "Ensure first task fired")
let throttler = Limiter(policy: .throttle, duration: 1)
var fulfillmentCount = 0
promise.expectedFulfillmentCount = 2
var value = ""
func accumulateAndSendToServer(_ input: String) async {
value += input
await throttler.submit { [value] in
// Then
switch fulfillmentCount {
case 0: XCTAssertEqual(value, "h")
case 1: XCTAssertEqual(value, "hello,")
default: XCTFail()
}
promise.fulfill()
fulfillmentCount += 1
}
}
// When
await accumulateAndSendToServer("h")
await accumulateAndSendToServer("e")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("o")
try await Task.sleep(seconds: 2)
await accumulateAndSendToServer(",")
await accumulateAndSendToServer(" ")
await accumulateAndSendToServer("w")
await accumulateAndSendToServer("o")
await accumulateAndSendToServer("r")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("d")
wait(for: [promise], timeout: 10)
}
```
As an aside:
1. In `debounce`, the test for `isCancelled` is redundant. The `Task.sleep` will throw an error if the task was canceled.
2. As a matter of convention, Apple uses `operation` for the name of the closure parameters, presumably to avoid confusion with `Task` instances.
3. I would change the `Task` to be a `Task<Void, Error>?`. Then you can simplify `debounce` to:
```swift
func debounce(operation: @escaping () async -> Void) {
task?.cancel()
task = Task {
defer { task = nil }
try await Task.sleep(seconds: duration)
await operation()
}
}
```
4. When throttling network requests for user input, you generally want to throttle the network requests, but not the accumulation of the user input. So I have pulled the `value += input` out of the throttler/debouncer. I also use a capture list of `[value]` to make sure that we avoid race conditions between the accumulation of user input and the network requests.
- - -
FWIW, this is my rendition of `Limiter`:
```swift
actor Limiter {
private let policy: Policy
private let duration: TimeInterval
private var task: Task<Void, Error>?
init(policy: Policy, duration: TimeInterval) {
self.policy = policy
self.duration = duration
}
func submit(operation: @escaping () async -> Void) {
switch policy {
case .throttle: throttle(operation: operation)
case .debounce: debounce(operation: operation)
}
}
}
// MARK: - Limiter.Policy
extension Limiter {
enum Policy {
case throttle
case debounce
}
}
// MARK: - Private utility methods
private extension Limiter {
func throttle(operation: @escaping () async -> Void) {
guard task == nil else { return }
task = Task {
defer { task = nil }
try await Task.sleep(seconds: duration)
}
Task {
await operation()
}
}
func debounce(operation: @escaping () async -> Void) {
task?.cancel()
task = Task {
defer { task = nil }
try await Task.sleep(seconds: duration)
await operation()
}
}
}
```
Which uses these extensions
```swift
// MARK: - Task.sleep(seconds:)
extension Task where Success == Never, Failure == Never {
/// Suspends the current task for at least the given duration
/// in seconds.
///
/// If the task is canceled before the time ends,
/// this function throws `CancellationError`.
///
/// This function doesn't block the underlying thread.
public static func sleep(seconds duration: TimeInterval) async throws {
try await Task.sleep(nanoseconds: UInt64(duration * .nanosecondsPerSecond))
}
}
// MARK: - TimeInterval
extension TimeInterval {
static let nanosecondsPerSecond = TimeInterval(NSEC_PER_SEC)
}
```
And the following tests:
```swift
final class LimiterTests: XCTestCase {
// test throttling as user enters “hello, world” into a text field
func testThrottler() async throws {
// Given
let promise = expectation(description: "Ensure first task fired")
let throttler = Limiter(policy: .throttle, duration: 1)
var fulfillmentCount = 0
promise.expectedFulfillmentCount = 2
var value = ""
func accumulateAndSendToServer(_ input: String) async {
value += input
await throttler.submit { [value] in
// Then
switch fulfillmentCount {
case 0: XCTAssertEqual(value, "h")
case 1: XCTAssertEqual(value, "hello,")
default: XCTFail()
}
promise.fulfill()
fulfillmentCount += 1
}
}
// When
await accumulateAndSendToServer("h")
await accumulateAndSendToServer("e")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("o")
try await Task.sleep(seconds: 2)
await accumulateAndSendToServer(",")
await accumulateAndSendToServer(" ")
await accumulateAndSendToServer("w")
await accumulateAndSendToServer("o")
await accumulateAndSendToServer("r")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("d")
wait(for: [promise], timeout: 10)
}
// test debouncing as user enters “hello, world” into a text field
func testDebouncer() async throws {
// Given
let promise = expectation(description: "Ensure last task fired")
let debouncer = Limiter(policy: .debounce, duration: 1)
var value = ""
var fulfillmentCount = 0
promise.expectedFulfillmentCount = 2
func accumulateAndSendToServer(_ input: String) async {
value += input
await debouncer.submit { [value] in
// Then
switch fulfillmentCount {
case 0: XCTAssertEqual(value, "hello")
case 1: XCTAssertEqual(value, "hello, world")
default: XCTFail()
}
promise.fulfill()
fulfillmentCount += 1
}
}
// When
await accumulateAndSendToServer("h")
await accumulateAndSendToServer("e")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("o")
try await Task.sleep(seconds: 2)
await accumulateAndSendToServer(",")
await accumulateAndSendToServer(" ")
await accumulateAndSendToServer("w")
await accumulateAndSendToServer("o")
await accumulateAndSendToServer("r")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("d")
wait(for: [promise], timeout: 10)
}
func testThrottler2() async throws {
// Given
let promise = expectation(description: "Ensure throttle before duration")
let throttler = Limiter(policy: .throttle, duration: 1)
var end = Date.now + 1
promise.expectedFulfillmentCount = 2
func test() {
// Then
XCTAssertLessThanOrEqual(.now, end)
promise.fulfill()
}
// When
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
try await Task.sleep(seconds: 2)
end = .now + 1
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
try await Task.sleep(seconds: 2)
wait(for: [promise], timeout: 10)
}
func testDebouncer2() async throws {
// Given
let promise = expectation(description: "Ensure debounce after duration")
let debouncer = Limiter(policy: .debounce, duration: 1)
var end = Date.now + 1
promise.expectedFulfillmentCount = 2
func test() {
// Then
XCTAssertGreaterThanOrEqual(.now, end)
promise.fulfill()
}
// When
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
try await Task.sleep(seconds: 2)
end = .now + 1
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
try await Task.sleep(seconds: 2)
wait(for: [promise], timeout: 10)
}
}
```
- - -
You might consider using [`debounce`][2] and [`throttle`][3] from Apple’s [Swift Async Algorithms][4] library.
[1]: https://github.com/apple/swift-evolution/blob/main/proposals/0306-actors.md#actor-reentrancy
[2]: https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/AsyncAlgorithms.docc/Guides/Debounce.md
[3]: https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/AsyncAlgorithms.docc/Guides/Throttle.md
[4]: https://github.com/apple/swift-async-algorithms