Relaxing `@Sendable` constraint to `sending` closures creates undesired warnings (errors in Swift 6 language mode)

Hello,

I found a compiler warning in Xcode 16.0 beta (16A5171c) that, I believe, goes against the spirit of sending introduced with SE-0430 sending parameter and result values.

In my understanding, it is nicer for users of an API in a function accepts a sending closure instead of a @Sendable closure:

// OK, but...
func acceptSendableClosure(completion: @escaping @Sendable () -> Void) { }

// ... this is better.
func acceptSendingClosure(completion: sending @escaping () -> Void) { }

The reason why it is better is that it's easier to provide a sending closure than a @Sendable one:

Demonstration
class NonSendable { }

func meh() {
    // ⚠️ Capture of 'ns' with non-sendable type 'NonSendable' in a
    // `@Sendable` closure; this is an error in the Swift 6 language mode
    let ns = NonSendable()
    acceptSendableClosure { print(ns) }
}

func yeah() {
    // No warning!
    let ns = NonSendable()
    acceptSendingClosure { print(ns) }
}

To sum up: friends don't let friends declare good old completion blocks as @Sendable. They should be sending.

Unfortunately, sending does not play well when called in the context of an actor-isolated function:

func acceptSendingClosure(completion: sending @escaping () -> Void) { }
func acceptSendableClosure(completion: @escaping @Sendable () -> Void) { }

func noWarning1() throws {
    // OK, no warning
    acceptSendingClosure { }
}

func noWarning2() throws {
    // OK, no warning
    acceptSendableClosure { }
}

@MainActor func noWarning3() throws {
    // OK, no warning
    acceptSendableClosure { }
}

@MainActor func createWarning() throws {
    // ⚠️ Main actor-isolated value of type '() -> ()' passed as a strongly
    // transferred parameter; later accesses could race; this is an error
    // in the Swift 6 language mode
    acceptSendingClosure { }
}

This last warning completely ruins the benefits of providing a sending closure in the first place.

"Ruins"? Isn't this hyperbolic?

You decide. Meanwhile, acceptSendingClosure can't be used in MainActor-isolated client code, which is (hum) pretty frequent. For example, it can not be tested without warning:

class MyTests: XCTestCase {

  @MainActor // required for `waitForExpectations`
  func testMyFunction() {
    let expectation = expectation(description: "completion")
    // ⚠️ Main actor-isolated ...
    acceptSendingClosure {
      expectation.fulfill()
    }
    waitForExpectations(timeout: 1)
  }
}

I should add that DispatchQueue.async(execute:) is expected to start accepting sending closures eventually. This warning implies that DispatchQueue.async would start emitting warnings whenever it is called from an isolated function. This sounds odd to me.

Should I open an issue?

Thanks in advance - cc @hborla and @Michael_Gottesman

9 Likes

Ah, so I guess the closure is inheriting the isolation requirement of the enclosing scope?

That makes some sense - the closure should be allowed to access local or MainActor state, at least by default, which logically implies the closure must indeed have that isolation.

Looks a bit silly in this case, though, where the closure does not access any state and does not need to be isolated to anything at all. I wonder if it's a solution to make the isolation inheritance [of the closure] contingent on it actually accessing isolated state?

Ah, so I guess the closure is inheriting the isolation requirement of the enclosing scope?

Yes. I don't know if this is normal. It wouldn't be absurd if only the body of the createWarning() function was MainActor-isolated.

Maybe it is possible to specify that the closure should not inherit the isolation of the enclosing scope, and remain nonisolated. I'm not aware of the syntax, though.

No, that's something else. I can't make any sense of this:

@globalActor actor MyGlobalActor: GlobalActor {
    static let shared = MyGlobalActor()
}

func acceptMainActorClosure(completion: @escaping @MainActor () -> Void) { }
func acceptMyGlobalActorClosure(completion: @escaping @MyGlobalActor () -> Void) { }

@MainActor func closures() {
    // ⚠️ Warning
    acceptSendingClosure { }
    
    // No warning
    acceptMainActorClosure { }
    
    // No warning
    acceptMyGlobalActorClosure { }
}

Indeed. I opened an issue: Swift 6: incorrect compiler error when passing a `sending` closure from a global-actor-isolated function · Issue #74382 · apple/swift · GitHub

Sorry for pinging you again, @hborla and @Michael_Gottesman, but those warnings are hard errors in the Swift 6 language mode, and this is pretty much a blocker to not being able to call a function that accepts a completion block.

I assume you agree that such functions, who call their closure argument once and don't use its eventual result, should accept a sending parameter for the sake of user convenience.

Very personally speaking, It would be appreciated to learn if I can rely on a fix in the beta phase. I have to decide the shape of the public api of my open source packages: should my completion blocks be sending (ideal) or @Sendable (not ideal as explained in the OP)?

4 Likes

Yes, we know this is an issue. The problem is the compiler infers that non-Sendable closures have the same isolation as the context they're formed in; the assumption is that closure will never leave the original isolation domain. That rule obviously should not apply if it's a sending closure, which is explicit indication that the closure will be sent to another isolation domain. It should be a straightforward compiler fix.

8 Likes

Woot! Thank you Holly for confirming that I can jump headfirst in sending, now assured that my library users will eventually not suffer from this warning or error :star_struck: