Skip to main content
2 of 5
incorporate race comment from below
Rob
  • 2.7k
  • 17
  • 27

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, and you introduce all sorts of unintended races.

However, you can add an actor-isolated function to Limiter:

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.:

func testThrottler() async throws {
    // Given
    let promise = expectation(description: "Ensure first task fired")
    let throttler = Limiter(policy: .throttle, duration: 1)
    var value = ""

    var fulfillmentCount = 0
    promise.expectedFulfillmentCount = 2

    func sendToServer(_ input: String) async {
        await throttler.submit {
            value += input

            // Then
            switch fulfillmentCount {
            case 0:
                XCTAssertEqual(value, "h")
            case 1:
                XCTAssertEqual(value, "hwor")
            default:
                XCTFail()
            }

            promise.fulfill()
            fulfillmentCount += 1
        }
    }

    // When
    await sendToServer("h")
    await sendToServer("e")
    await sendToServer("l")
    await sendToServer("l")
    await sendToServer("o")

    await sleep(2)

    await sendToServer("wor")
    await sendToServer("ld")

    wait(for: [promise], timeout: 10)
}

Repeat that process for all the tests, and you'll see it works fine.

As an aside:

  1. In debounce, the test for isCancelled is redundant. The try await 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:

    func debounce(operation: @escaping () async -> Void) {
        task?.cancel()
    
        task = Task {
            try await sleep()
            await operation()
            task = nil
        }
    }
    

FWIW, this is my rendition of Limiter:

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 {
            try? await sleep()
            task = nil
        }

        Task {
            await operation()
        }
    }

    func debounce(operation: @escaping () async -> Void) {
        task?.cancel()

        task = Task {
            try await sleep()
            await operation()
            task = nil
        }
    }

    func sleep() 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:

final class LimiterTests: XCTestCase {
    func testThrottler() async throws {
        // Given
        let promise = expectation(description: "Ensure first task fired")
        let throttler = Limiter(policy: .throttle, duration: 1)
        var value = ""

        var fulfillmentCount = 0
        promise.expectedFulfillmentCount = 2

        func sendToServer(_ input: String) async {
            await throttler.submit {
                value += input

                // Then
                switch fulfillmentCount {
                case 0:  XCTAssertEqual(value, "h")
                case 1:  XCTAssertEqual(value, "hwor")
                default: XCTFail()
                }

                promise.fulfill()
                fulfillmentCount += 1
            }
        }

        // When
        await sendToServer("h")
        await sendToServer("e")
        await sendToServer("l")
        await sendToServer("l")
        await sendToServer("o")

        await sleep(2)

        await sendToServer("wor")
        await sendToServer("ld")

        wait(for: [promise], timeout: 10)
    }

    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 sendToServer(_ input: String) async {
            await debouncer.submit {
                value += input

                // Then
                switch fulfillmentCount {
                case 0:  XCTAssertEqual(value, "o")
                case 1:  XCTAssertEqual(value, "old")
                default: XCTFail()
                }

                promise.fulfill()
                fulfillmentCount += 1
            }
        }

        // When
        await sendToServer("h")
        await sendToServer("e")
        await sendToServer("l")
        await sendToServer("l")
        await sendToServer("o")

        await sleep(2)

        await sendToServer("wor")
        await sendToServer("ld")

        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)

        await sleep(2)
        end = .now + 1

        await throttler.submit(operation: test)
        await throttler.submit(operation: test)
        await throttler.submit(operation: test)

        await sleep(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)

        await sleep(2)
        end = .now + 1

        await debouncer.submit(operation: test)
        await debouncer.submit(operation: test)
        await debouncer.submit(operation: test)

        await sleep(2)

        wait(for: [promise], timeout: 10)
    }

    private func sleep(_ duration: TimeInterval) async {
        try? await Task.sleep(nanoseconds: UInt64(duration * .nanosecondsPerSecond))
    }
}
Rob
  • 2.7k
  • 17
  • 27