Actor isolation not inherited when calling `@objc` func

I discovered today that in certain cases, a nonisolated(nonsending) func does not receive the caller’s isolation, and thus ends up unexpectedly running outside of the caller’s context. A simple repro example follows:

class MyObject {}

extension MyObject {

  nonisolated(nonsending)
  func swiftFunc() async {
    MainActor.assertIsolated()
  }

  @objc nonisolated(nonsending)    
  func objcFunc() async {
    MainActor.assertIsolated()
  }
}


@MainActor func test() async {
  let obj = MyObject()

  await obj.swiftFunc() // This is fine
  await obj.objcFunc()  // This crashes
}

As expected, swiftFunc() is called on the main actor.

We would expect objcFunc() to be called on the main actor as well, since its caller is on the main actor. However, it is actually called on one of the concurrency threads. I suspect that it’s bouncing through an ObjC thunk, and that thunk is losing the caller’s isolation.

In order to reproduce this, the called function must be:

  • Annotated @objc
  • async
  • nonisolated(nonsending) (enabled by default with Approachable Concurrency)
  • Declared in an extension of a class, not in the main body.

I’ve looked through SE-0461 (which introduced nonisolated(nonsending)), and I don’t see any discussion of calling @objc functions. Is this a bug? Or is this unavoidable expected behavior?

If it’s unavoidable, should the compiler diagnose an error in this scenario, since the function cannot fulfill the nonsending contract?

2 Likes

I’m fairly confident that this should fail to compile. If I understand right, a nonisolated(nonsending) function very close to an isolated parameter, and that doesn’t work.

@objc
// error: Method cannot be marked '@objc' because the type of the parameter cannot be represented in Objective-C
func objcParamFunc(isolation: isolated (any Actor)?) async {
  MainActor.assertIsolated()
}

I don’t see how this is meaningfully different. An actor instance (or nil) needs to be supplied at the callsite. That requires both compiler participation as well as an actual actor instance to include. I don’t think you can get either from the ObjC side.

So under NonisolatedNonsendingByDefault, would that mean that every @objc async function must either be @concurrent or otherwise isolated to some specific actor? There’s no way to spell “Just let ObjC call this from whatever context it’s currently in, even though it won’t have an isolation to pass in”. (And probably there shouldn’t be a way to do that, because if you had it, you wouldn’t be able to resume to the same context after suspending.)

2 Likes

I’m kind of guessing here, but I think you’ve got it. The argument needs to be known on the Swift side. It cannot originate from the caller.

If you imagine that this failed to compile, then your only option is here is a synchronous method. If you need a callback, my gut says a non-sendable function could help ease the implementation on the Swift side, but I’m not 100% sure that’s true and I have not experimented.

Hm…. with nonisolated(nonsending) becoming the default behavior… does this mean that @objc async functions (in class extensions) are broken when that default is enabled? Or do they silently infer some other behavior?

EDIT: Indeed, it does mean that.

1 Like