WatchConnectivity Swift 6 - Incorrect actor executor assumption

I am trying to migrate a WatchConnectivity App to Swift6 and I found an Issue with my replyHandler callback for sendMessageData.

I am wrapping sendMessageData in withCheckedThrowingContinuation, so that I can await the response of the reply. I then update a Main Actor ObservableObject that keeps track of the count of connections that have not replied yet, before returning the data using continuation.resume.

...
@preconcurrency import WatchConnectivity

actor ConnectivityManager: NSObject, WCSessionDelegate {
  private var session: WCSession = .default
  private let connectivityMetaInfoManager: ConnectivityMetaInfoManager

  ...

  private func sendMessageData(_ data: Data) async throws -> Data? {
    Logger.shared.debug("called on Thread \(Thread.current)")

    await connectivityMetaInfoManager.increaseOpenSendConnectionsCount()

    return try await withCheckedThrowingContinuation({
      continuation in
      self.session.sendMessageData(
        data,
        replyHandler: { data in
          Task {
            await self.connectivityMetaInfoManager
              .decreaseOpenSendConnectionsCount()
          }
          continuation.resume(returning: data)
        },
        errorHandler: { (error) in
          Task {
            await self.connectivityMetaInfoManager
              .decreaseOpenSendConnectionsCount()
          }
          continuation.resume(throwing: error)
        }
      )
    })
  }

Calling sendMessageData somehow causing the app to crash and display the debug message: Incorrect actor executor assumption.

The code runs on swift 5 with SWIFT_STRICT_CONCURRENCY = complete. However when I switch to swift 6 the code crashes.

I rebuilt a simple version of the App. Adding bit by bit until I was able to cause the crash. See Broken App

Awaiting sendMessageData and wrapping it in a task and adding the @Sendable attribute to continuation, solve the crash. See Fixed App

But I do not understand why yet.

Is this intended behaviour? Should the compiler warn you about this? Is it a WatchConnectivity issue?

I initially posted on forums.swift.org, but was told to repost here.

Answered by DTS Engineer in 819894022

I believe the crash (with the "Incorrect actor executor assumption" message) is intentional. When you use the Swift 6 mode to build your app, the compiler adds some runtime checks to detect problems that could otherwise cause a data race. If a check is not satisfied, it triggers a crash.

I am by no means an expert of Swift compiler, but here is my theory about what happens in your case:

a. ConnectivityManager is actor-isolated, meaning that, in your code snippet, self.session.sendMessageData(...), the replyHandler closure, and also the errorHandler closure are supposed to run in the actor's executor.

b. WCSession.sendMessageData(...) doesn't really run replyHandler (or errorHandler) in the calling queue. This is mentioned in the API reference: "If you specify a reply handler block, your handler block is executed asynchronously on a background thread."

For some reason, the compiler doesn't detect the problem at the compile time, but at runtime, replyHandler (or errorHandler) is running in a queue different from the actor's executor. That triggers a runtime check failure, and hence the crash.

To fix the issue, you can consider making the closures passed to WCSession.sendMessageData(...) sendable, which tells the compiler that the closure can be safely run in any thread, and hence should eliminate the crash:

    return try await withCheckedThrowingContinuation({
      continuation in
      self.session.sendMessageData(
        data,
        replyHandler: { @Sendable data in // Mark the replyHandler closure as sendable.
          Task {
            await self.connectivityMetaInfoManager
              .decreaseOpenSendConnectionsCount()
          }
          continuation.resume(returning: data)
        },
        errorHandler: { @Sendable (error) in // Mark the errorHandler closure as sendable.
          Task {
            await self.connectivityMetaInfoManager
              .decreaseOpenSendConnectionsCount()
          }
          continuation.resume(throwing: error)
        }
      )
    })

You can give it a try and follow up here if this doesn't help.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

I believe the crash (with the "Incorrect actor executor assumption" message) is intentional. When you use the Swift 6 mode to build your app, the compiler adds some runtime checks to detect problems that could otherwise cause a data race. If a check is not satisfied, it triggers a crash.

I am by no means an expert of Swift compiler, but here is my theory about what happens in your case:

a. ConnectivityManager is actor-isolated, meaning that, in your code snippet, self.session.sendMessageData(...), the replyHandler closure, and also the errorHandler closure are supposed to run in the actor's executor.

b. WCSession.sendMessageData(...) doesn't really run replyHandler (or errorHandler) in the calling queue. This is mentioned in the API reference: "If you specify a reply handler block, your handler block is executed asynchronously on a background thread."

For some reason, the compiler doesn't detect the problem at the compile time, but at runtime, replyHandler (or errorHandler) is running in a queue different from the actor's executor. That triggers a runtime check failure, and hence the crash.

To fix the issue, you can consider making the closures passed to WCSession.sendMessageData(...) sendable, which tells the compiler that the closure can be safely run in any thread, and hence should eliminate the crash:

    return try await withCheckedThrowingContinuation({
      continuation in
      self.session.sendMessageData(
        data,
        replyHandler: { @Sendable data in // Mark the replyHandler closure as sendable.
          Task {
            await self.connectivityMetaInfoManager
              .decreaseOpenSendConnectionsCount()
          }
          continuation.resume(returning: data)
        },
        errorHandler: { @Sendable (error) in // Mark the errorHandler closure as sendable.
          Task {
            await self.connectivityMetaInfoManager
              .decreaseOpenSendConnectionsCount()
          }
          continuation.resume(throwing: error)
        }
      )
    })

You can give it a try and follow up here if this doesn't help.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks for the explanation!

For some reason, the compiler doesn't detect the problem at the compile time

Should I report this somewhere?

To fix the issue, you can consider making the closures passed to WCSession.sendMessageData(...) sendable, which tells the compiler that the closure can be safely run in any thread

Makes sense. That also works. I created a new PR to show it. Fix Ziqiao Chen

Would it still make sense to mark continuation? Does it make sense to have both marked as @Sendable?

Is marking the parameters better than doing so to continuation? No real difference?

Does any of it mess with on which Thread the code is run?

It does not seem like it. But hard to tell.

@Sendable data & @Sendable error

ConnectivityManager.swift: sendExample(): called on Thread <NSThread: 0x60000177a340>{number = 7, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): called on Thread <NSThread: 0x60000177a340>{number = 7, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): withCheckedThrowingContinuation called on Thread <NSThread: 0x60000177a340>{number = 7, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): replyHandler called on Thread <NSThread: 0x6000017544c0>{number = 10, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): replyHandler task called on Thread <NSThread: 0x600001720c00>{number = 6, name = (null)}

@Sendable continuation

ConnectivityManager.swift: sendExample(): called on Thread <NSThread: 0x6000017580c0>{number = 3, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): called on Thread <NSThread: 0x6000017580c0>{number = 3, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): withCheckedThrowingContinuation called on Thread <NSThread: 0x6000017580c0>{number = 3, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): replyHandler called on Thread <NSThread: 0x60000171ff00>{number = 11, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): replyHandler task called on Thread <NSThread: 0x60000171c500>{number = 12, name = (null)}

@Sendable continuation & @Sendable data & @Sendable error

ConnectivityManager.swift: sendExample(): called on Thread <NSThread: 0x60000172ed40>{number = 5, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): called on Thread <NSThread: 0x60000172ed40>{number = 5, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): withCheckedThrowingContinuation called on Thread <NSThread: 0x60000172ed40>{number = 5, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): replyHandler called on Thread <NSThread: 0x60000176a240>{number = 12, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): replyHandler task called on Thread <NSThread: 0x60000172ed40>{number = 5, name = (null)}

No @Sendable

ConnectivityManager.swift: sendExample(): called on Thread <NSThread: 0x600001783300>{number = 8, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): called on Thread <NSThread: 0x600001783300>{number = 8, name = (null)}
ConnectivityManager.swift: sendMessageData(_:): withCheckedThrowingContinuation called on Thread <NSThread: 0x600001783300>{number = 8, name = (null)}
Incorrect actor executor assumption

Would it still make sense to mark continuation? Does it make sense to have both marked as @Sendable? ... Is marking the parameters better than doing so to continuation? No real difference?

The continuation conforms to Sendable, and so I don't think you need to add an extra annotation.

Does any of it mess with on which Thread the code is run?

No, WatchConnectivity still runs the replyHandler (or errorHandler) "asynchronously on a background thread".

Marking the closure Sendable only tells the compiler that the closure is safe to be run in any thread, which calms down the check. Note that it is your responsibility to make sure that the closure is indeed thread-safe.

The same trick is discussed here, btw.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

WatchConnectivity Swift 6 - Incorrect actor executor assumption
 
 
Q