Search code examples
swiftactorswift-concurrencysendable

Can I call an actor's function from a @Sendable sync function?


I'm not sure if this is even possible, but I thought I should ask anyway!

I have a "UserDefaults" client and "live"/"mock" implementations of its interface (taken from a TCA example, but that shouldn't matter).

I want to have the mock implementation act like the live one (so that it should be possible to read/write values), but without actually persisting.

Here's my code (ready for a Playground):

import Foundation
import PlaygroundSupport

// Interface - this can't change
public struct UserDefaultsClient {
    public var boolForKey: @Sendable (String) -> Bool
    public var setBool: @Sendable (Bool, String) async -> Void
}

// Live implementation (ignore if you want, just for reference)
extension UserDefaultsClient {
  public static let live: Self = {
    let defaults = { UserDefaults(suiteName: "group.testing")! }
    return Self(
      boolForKey: { defaults().bool(forKey: $0) },
      setBool: { defaults().set($0, forKey: $1) }
    )
  }()
}

// Mock implementation
extension UserDefaultsClient {
    static let mock: Self = {
        let storage = Storage()
        return Self(
            boolForKey: { key in
                // ❌ Compiler error: Cannot pass function of type '@Sendable (String) async -> Bool' to parameter expecting synchronous function type
                return await storage.getBool(for: key)
                // return false
            },
            setBool: { boolValue, key in
                await storage.setBool(boolValue, for: key)
            }
        )
    }()
}

// Manages a storage of Bools, mapped to a key
actor Storage {
    var storage: [String: Bool] = [:]

    func setBool(_ bool: Bool, for key: String) async {
        storage[key] = bool
    }

    func getBool(for key: String) -> Bool {
        storage[key] ?? false
    }
}

let client: UserDefaultsClient = .live
Task {
    client.boolForKey("key")
    await client.setBool(true, "key")
    client.boolForKey("key")
}

PlaygroundPage.current.needsIndefiniteExecution = true

I tried using a Task when calling storage.getBool, but that didn't work either.

Any ideas?


Solution

  • tl;dr the answer is No, there is no way to maintain both the interface that boolForKey is synchronous but have it perform an asynchronous operation - while maintaining the model that makes async/await valuable.

    You are asking how to run an asynchronous task synchronously. It's kind of like asking how to make dry water. You are going against the fundamental nature of the asyc/await paradigm and its primitives.

    You might be able to accomplish what you with synchronization primitives like DispatchSemaphore or something similar, but you'd be working around the system. You'd have no guarantees about future compatibility. And you would likely introduce subtle and pernicious bugs.

    Consider, for example, if some call path in your actor ever managed to come back around and call boolForKey. That would probably be a deadlock.