Does dynamic lookup of @objc async methods break cancellation?

Do I understand correctly, that the following code does double bridging of async -> completion handler -> async, and as a result call of the async method is implicitly wrapped into an unstructured task?

@objc protocol Loader {
    func load() async throws -> String
}

class LoaderImpl: NSObject, Loader {
    func load() async throws -> String { ... }
}

func use(loader: Loader) async throws {
     // Will cancelling use() cancel LoaderImpl.load()?
     print(try await loader.load())
}

Is there any diagnostics for this? What would be workaround?

We're working to make this simply resume the calling task, but that work probably won't be done in 5.7. I'm not sure there's an effective way to work around it besides finding some way to not go through an ObjC interface.

Not sure how if it is possible to skip ObjC interface without violating LSP.

Would it be possible to allocate two "slots" in the interface for such methods, and use second one to smuggle async entry point?

Something like this:

@protocol Loader <NSObject>
- (void)loadWithCompletionHandler:(void(^)(NSString *result, NSError *error))completionHandler;
@optional
/// Extra indirection just to make sure that async calling convention doesn't interfere with ObjC runtime.
/// If that is not a concern, we could store pointer to async func as IMP of this method.
- (void*)__get_async_load_impl;
@end
func use(loader: Loader) async throws {
    let result: String
    
    if loader.responds(to: #selector(__get_async_load)) {
          let impl = unsafeBitCast(loader.__get_async_load_impl(), to: (@convention(thin) (AnyObject) async throws -> String).self) 
          result = try await impl(loader)
    } else {
         result = try await withUnsafeThrowingContinuation { cont in
            ...
         }
    }

    print(result)
}

Another approach would be to embrace cancellation in ObjC interoperability and generate the following:

@protocol Loader <NSObject>
- (void)loadWithCanceller:(NSCanceller *)canceller completionHandler:(void(^)(NSString *result, NSError *error))completionHandler;
@end
extension LoaderImpl {
    @objc func load(canceller: NSCanceller, completionHandler:  @convention(block) (NSString?, NSError?) {
        let t = Task {
            do {
                let t = try await self.load()
                completionHandler(t as NSString, nil)
            } catch {
                completionHandler(nil, error as NSError)
            }
        }
        canceller.addHandler(t.cancel)
    }
}

func use(loader: Loader) async throws {
     let c = NSCanceller()
     let r = try await withCancellationHandler(c.cancel) {
         try await withUnsafeThrowingContinuation { cont in
             self.load(canceller: c) { result, error in
                 ...
             }
         }
     }
     print(try await loader.load())
}

The latter approach would work well even in cases when we need to do double bridging with arbitrary complex code in between.

And there seems to be some demand for standard thread-safe canceller type anyway - How to use withTaskCancellationHandler properly?

I'm explaining to you the current situation. Like I said, we're looking to fix it.

1 Like