kelan.io

Promise Lifetime Question

I’m starting to explore using Promises more in Swift, especially after reading Soroush Khanlou’s posts about them. I like the idea a lot, but I have some questions about best practices for managing the lifetimes of related objects (for specific use-cases).

Motivating Example

I’m trying to wrap some preexisting Cocoa API that uses the delegate pattern for callbacks, and making an API that returns a Promise.

Let’s say you have this API that uses a delegate for callback from some async operation:

protocol ExampleAPIDelegate: NSObjectProtocol {
    func workCompleted(result: String)
}

class ExampleAPI {
    static var sharedInstance: ExampleAPI

    weak var delegate: ExampleAPIDelegate?

    /// The result of this async work comes via the delegate callback.
    func startWork() {  }
}

Here’s how you might “wrap” it, so that you can deal with it by using a Promise instead:

/// The enum is just for namespacing
enum ExampleWrapper {

    /// Private helper to serve as the delegate
    private class DelegateShim: ExampleAPIDelegate {
        let fulfill: (String) -> Void
        let reject: (Error) -> Void

        init (fulfill: @escaping (String) -> Void, reject: @escaping (Error) -> Void) {
            self.fulfill = fulfill
            self.reject = reject
        }

        func workCompleted(result: String) {
            fulfill(result)
        }
    }

    static func promisedWorkResult() -> Promise<String> {
        return Promise(work: { (fulfill, reject) in
            let shim = DelegateShim(fulfill: fulfill, reject: reject)
            ExampleAPI.sharedInstance.delegate = shim
            ExampleAPI.sharedInstance.startWork()
        })
    }
}

Which is nice to use:

ExampleWrapper.promisedWorkResult().then { result in
    print("got \(result)")
}

Specifically, the advantages are:

The Problem: DelegateShim’s lifetime

But, there is still a memory management problem with this specific example. Nothing is keeping a strong reference to the DelegateShim object, aside from work closure. But that closure completes (and releases its references) before the delegate callback comes (when the async ExampleAPI work finishes). So, the DelegateShim object is gone by that point.

Possible Solutions

  1. One thought I had was to make a subclass of Promise, specific for this use case, and have that hold the DelegateShim object as a property. But, Soroush’s implementation of Promise is a final class , so I couldn’t subclass it. That could be changed, but it gave me pause, because I’m not sure if making it final was arbitrary, or if there really is a good reason to force you never to subclass it.

  2. Another idea would be to use objc_set_associated_object() for the promise and delegateShim, but that seems very unappealing, especially because it ties us back to all the ObjC runtime and NSObject baggage that I’m trying to avoid.

  3. If the work block (passed to Promise.init(work:) was held until the Promise fulfilled, that would solve this issue. The local reference to the DelegateShim in that closure would be enough to keep it alive until it’s fulfilled. But, that requires a change to the Promise code. And, maybe that isn’t good in most other use cases for Promises? Maybe it makes it too easy to create reference cycles and leak objects?

  4. If we want to leave Promise as-is, we can manually approximate idea #3 by blocking at the end of the work closure, so it doesn’t return until we’re done. For example, we could wait on a semaphore that we don’t signal until after the promise has been fulfilled. That would look like this:

enum ExampleWrapper {

    private class DelegateShim: ExampleAPIDelegate {
        let fulfill: (String) -> Void
        let reject: (Error) -> Void

        /// Use this to block the `work` closure until the delegate callback comes, to keep the
        /// DelegateShim alive.
        let doneSemaphore = DispatchSemaphore(value: 0)

        init (fulfill: @escaping (String) -> Void, reject: @escaping (Error) -> Void) {
            self.fulfill = fulfill
            self.reject = reject
        }

        func somethingHappened(event: String) {
            fulfill(event)
            doneSemaphore.signal()
        }
    }

    func start() -> Promise<String> {
        return Promise(work: { (fulfill, reject) in
            let shim = DelegateShim(fulfill: fulfill, reject: reject)
            ExampleAPI.sharedInstance.delegate = shim
            ExampleAPI.sharedInstance.start()
            shim.doneSemaphore.wait()   // <-- This makes this block not exit until the semaphore is signaled
        })
    }
}

Feedback Welcome

Are there other ideas that I’m missing? Is there already a common pattern that I should use here? Or, am I “holding it wrong”, for whatever reason? Let me know!