WatchConnectivity Swift 6 - Incorrect actor executor assumption

I trying to migrate a WatchConnectivity App to Swift6 and I am stuck migrating the nonisolated delegate callback for didReceiveMessageData.

The code runs on swift 5 with SWIFT_STRICT_CONCURRENCY = complete.
However when I switch to swift 6 the code crashes with the debug message. Incorrect actor executor assumption.

This is the delegate that works fine before changing the compiler to swift 6.

import Foundation
@preconcurrency import WatchConnectivity

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

    override init() {
        super.init()

        self.session.delegate = self
        self.session.activate()
    }


    // all the other WCSessionDelegate functions
    // ...

    nonisolated func session(
        _ session: WCSession,
        didReceiveMessageData messageData: Data,
        replyHandler: @escaping @Sendable (Data) -> Void
    ) {
            // Simplified for the question ..
           Task {
                let data = try await asyncWork()
                replyHandler(data)
           }
        }
    }
}

After enabling SWIFT_STRICT_CONCURRENCY. The compiler originally warned me about a potential data race that will be an error in Swift 6.

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure.
  Closure captures 'replyHandler' which is accessible to code in the current task

This warning is solvable by adding the @Sendable attribute to the replyHandler signature and @preconcurrency to the WatchConnectivity import. (Reflected in the code example).

Compiler is happy. Everything compiles and works.

But then after switching the compiler Swift Language Version to Swift 6. The app crashes after trying to access the replyHandler and outputs Incorrect actor executor assumption.

I have also unsuccessfully tried changing ConnectivityManager to a @MainActor class and/or only calling replyHandler from the main Thread.

await MainActor.run {
    replyHandler(data)
}

After further testing I found that it seems to be related to using @preconcurrency with the WatchConnectivity import.

This crashes every time (with swift 6):

import Foundation
@preconcurrency import WatchConnectivity

actor ConnectivityManager: NSObject, WCSessionDelegate {

    // ...

    nonisolated func session(
        _ session: WCSession,
        didReceiveMessageData messageData: Data,
        replyHandler: @escaping (Data) -> Void
    ) {
            replyHandler(data)
        }
    }
}

This never does:

import Foundation
import WatchConnectivity

actor ConnectivityManager: NSObject, WCSessionDelegate {

    // ...

    nonisolated func session(
        _ session: WCSession,
        didReceiveMessageData messageData: Data,
        replyHandler: @escaping (Data) -> Void
    ) {
            replyHandler(data)
        }
    }
}

(However both work with swift 5)

Backtrace:

Incorrect actor executor assumption
(lldb) bt
* thread #12, stop reason = signal SIGABRT
  * frame #0: 0x2937ec94 libsystem_kernel.dylib`__pthread_kill + 8
    frame #1: 0x29a5f3c8 libsystem_pthread.dylib`pthread_kill + 208
    frame #2: 0x222fe53c libsystem_c.dylib`abort + 124
    frame #3: 0x24581c70 libswift_Concurrency.dylib`swift::swift_Concurrency_fatalErrorv(unsigned int, char const*, char*) + 28
    frame #4: 0x24581c8c libswift_Concurrency.dylib`swift::swift_Concurrency_fatalError(unsigned int, char const*, ...) + 28
    frame #5: 0x24581904 libswift_Concurrency.dylib`swift_task_checkIsolated + 152
    frame #6: 0x2457ebd8 libswift_Concurrency.dylib`swift_task_isCurrentExecutorImpl(swift::SerialExecutorRef) + 280
    frame #7: 0x033c2440 tricorder Watch App.debug.dylib`closure #1 in closure #2 in ConnectivityManager.sendMessageData(data=15 bytes, continuation=(canary = 0x0000000014d0bf90)) at <stdin>:0
    frame #8: 0x033c2a30 tricorder Watch App.debug.dylib`thunk for @escaping @callee_guaranteed (@guaranteed Data) -> () at <compiler-generated>:0
    frame #9: 0x592c14e4 WatchConnectivity`__61-[WCSession onqueue_handleResponseData:record:withPairingID:]_block_invoke + 180
    frame #10: 0x206f6978 Foundation`__NSINDEXSET_IS_CALLING_OUT_TO_A_BOOL_BLOCK__ + 16
    frame #11: 0x20746384 Foundation`-[NSBlockOperation main] + 100
    frame #12: 0x206ff6b8 Foundation`__NSOPERATION_IS_INVOKING_MAIN__ + 12
    frame #13: 0x20706b9c Foundation`-[NSOperation start] + 620
    frame #14: 0x2071e56c Foundation`__NSOPERATIONQUEUE_IS_STARTING_AN_OPERATION__ + 12
    frame #15: 0x2071a03c Foundation`__NSOQSchedule_f + 168
    frame #16: 0x03234aec libdispatch.dylib`_dispatch_call_block_and_release + 24
    frame #17: 0x03236404 libdispatch.dylib`_dispatch_client_callout + 16
    frame #18: 0x032390b8 libdispatch.dylib`_dispatch_continuation_pop + 564
    frame #19: 0x03238540 libdispatch.dylib`_dispatch_async_redirect_invoke + 652
    frame #20: 0x03247b44 libdispatch.dylib`_dispatch_root_queue_drain + 336
    frame #21: 0x03248468 libdispatch.dylib`_dispatch_worker_thread2 + 192
    frame #22: 0x29a59780 libsystem_pthread.dylib`_pthread_wqthread + 220

Any ideas what I am doing wrong?

Edit 2024.11.17: Fixed some wording.
Edit 2024.11.17: Added backtrace.

1 Like

This is very specific to Apple APIs. Given that no one else has chimed in, my advice is that you repost over in Apple Developer Forums and I’ll try to help out there. Put your thread in the App & System Services > Processes & Concurrency topic area and tag it Swift, Concurrency, and Watch Connectivity. That way I’ll be sure to see it go by (-:

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

Thanks @eskimo. I found a solution yesterday evening.
But tbh I do not understand why it works yet.
Should I still repost to Apple Developer Forums?

I tried to rebuild a simple WatchConnectivity app. Mirroring the implementation of my App. I then started adding more stuff until it broke.

Turns out I was wrong. It was not the didReceiveMessageData replyHandler but actually the sendMessageData replyHandler function that was causing the issue.

Which was obvious in hindsight by looking at the backtrace.

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.

Somehow this was causing the Incorrect actor executor assumption.

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

But like I said, I do not understand why yet.

Here is the repo of the simple WatchConnectivity app and the fix.

If anyone is interested.