Swift 6 withTaskCancellationHandler / withCheckedContinuation

While this was possible Beta 5, Xcode 16 Beta 6 raises a concurrency warning / error in Swift 6 language mode:

func withContinuation<T>(
  isolation: isolated (any Actor)? = #isolation,
  body: (CheckedContinuation<T, Never>) -> Void
) async -> T {
    await withTaskCancellationHandler {
        await withCheckedContinuation(isolation: isolation) {
            body($0) // ❌ error: sending 'body' risks causing data races
        }
    } onCancel: {

    }
}

Is this a valid error or compiler bug or is there another way to wrap these functions without reaching for underscored attributes?

1 Like

I think the reason is that in this form

compiler cannot detect that both with functions have the same isolation. Passing isolation explicitly to the withTaskCancellationHandler doesn't fix the issue. While removing passing isolation to withCheckedContinuation does. I think it is safe to drop an explicit isolation passing here, which makes it work.

Unfortunately, removing the isolation compiles, but (on macOS 14) it appears to no longer be isolated:

actor Foo {
  func bar() async {
    await withContinuation {
      assertIsolated()  // 💣 Fatal error: Incorrect actor executor assumption; Expected 'UnownedSerialExecutor(executor: (Opaque Value))' executor.
      $0.resume()
    }
  }
}

await Foo().bar()
1 Like

On macOS 15 as well, that's odd to me: I would've expected isolation to propagate, given that withTaskCancellationHandler simply calls operation in the end. However, if I put body of withContinuation method inside actor's method directly, it works fine. So probably it is an issue somewhere, because I'd expect either be able to explicitly pass isolation to both functions or have it propagated all the way.

FYI the warning can be silenced with nonisolated(unsafe)

func withContinuation<T>(
  isolation: isolated (any Actor)? = #isolation,
  body: (CheckedContinuation<T, Never>) -> Void
) async -> T {
   nonisolated(unsafe) let body = body
   return await withTaskCancellationHandler {
        await withCheckedContinuation(isolation: isolation) {
            body($0) // ✅
        }
    } onCancel: {

    }
}

Isn’t this a correct error simply because the isolation parameter is merely a default? If you passed a different actor in for the isolation than what body is isolated to, you’d be in trouble. I’m still a novice at the concurrency stuff though…

EDIT: No, if that were the case then you’d never be able to use a non-Sendable body in an async function at all, independent of the continuation parts.

2 Likes

Yep the synchronous body closure is only safe to execute with the isolation it was formed in and the compiler correctly prevents it from being sent across isolation domains.

It shouldn't be possible for the caller to form a reference to any other isolation than the current. The compiler does prevent the caller passing isolation: nil

private var continuation: CheckedContinuation<T, Never>?
...
await withContinuation(isolation: nil) {
  continuation = $0 // ❌'Sending 'self'-isolated value of type '(CheckedContinuation<T, Never>) -> ()' with later accesses to nonisolated context risks causing data races
}

The compiler just seems to lose track of this when withCheckedContinuation is nested within withTaskCancellationHandler. All the testing I have performed via Actor.assertIsolated() suggests that isolation is preserved.