Why does assumeIsolated() now require T: Sendable?

Swift 6 has changed Actor.assumeIsolated() from assumeIsolated<T>(_ operation: (isolated Self) throws -> T) rethrows -> T to assumeIsolated<T: Sendable>(_ operation: (isolated Self) throws -> T) rethrows -> T, and this change does not make any sense to me. The entire premise of assumeIsolated is that the caller must already be in the actor's isolation and returning a value from the block cannot possibly switch isolation domains.

This change makes the function require stupid nonsense with boxing values in a @unchecked Sendable wrapper.

[e] Looks like captured variables also need to be Sendable as well now? This is a pretty major regression in the usability of non-Sendable values and I was under the impression that improving that was one of the goals of Swift 6.

This was discussed during the review of SE-0414:

2 Likes

I gotta say that despite how excited I initially was for RBI it's increasingly made me just have no fucking clue about what is and isn't safe with Swift concurrency, and the strategy of just bouncing off the compiler's guardrails has worked pretty terribly due to that every single version has declared a bunch of things that previously appeared to be safe to actually be unsafe.

3 Likes

This was before sending got introduced. It is still strange as it is just a precondition. However, as it just asserts that we are on the same executor it might still require this.

At least Mutex now doesn’t require its closure to be sendable anymore and neither does its return value. It now uses sending instead. cc @John_McCall

1 Like

The assumption that passing a non-Sendable object to a non-isolated function cannot introduce references to it from specific concurrent contexts is a critical part of what makes region-based isolation usably useful. Without that rule, you would need annotations on every function you passed the object to, all declaring that the object remains locally isolated after the call, in order to be able to send it later.

We do understand that that rule requires somewhat hard-to-understand sendability restrictions on assumeIsolated. You should be able to use nonisolated(unsafe) as a workaround when necessary. The better long-term fix is usually to reduce your use of assumeIsolated and find a way to statically propagate the isolation you’re relying on.

2 Likes

During review of region isolation some folks thought that maybe a -> sending T would help resolve the issue, but that's sadly not the case -- it'd open up the same kinds of holes.

So unless we find some other way to track that the assumeIsolated potentially "escaped" something related to the actor's isolation, we won't be able to loosen up this restriction.

1 Like

I do not understand this example. This is calling assumeIsolated in a context which is not isolated to that actor. How is this not just a runtime fatal error?

The two places we use assumeIsolated are after a hop through obj-c or c++ (which cannot propagate isolated parameters unless there's some attribute I missed), and in implementations of DynamicProperty.update() which is a synchronous function that needs to be able to share access to mutable data with @MainActor things, and so with the previous "don't use locks" messaging the only way to implement it was to rely on the fact that it happened to always be called on the main thread.

1 Like

Non-isolated synchronous functions dynamically maintain whatever the isolation of their caller was.

Sure, this is one of the cases that you do need assumeIsolated for. And you also need to share a non-Sendable value across that boundary?

And the caller is a non-isolated async function, so it maintains the lack of isolation? If unsound() was isolated to actor1 then the assumeIsolated would work, but you also wouldn't need it as you could just read x directly (if it was public).

The basic flow is that we asynchronously call a method on an actor which synchronously invokes some obj-c++ that constructs some non-sendable objects and synchronously passes both the actor and those objects to a user-provided callback (at a very specific time; the obj-c++ code can't just return the objects unfortunately). It's all pretty straightforward to do with an @unchecked Sendable wrapper, but I don't understand why that wrapper is needed.

Ah, you mean the complete original example, not just the part quoted. Yes, the async function there needs to be isolated to actor1. Then the problem is that region analysis would think get returns a disconnected value and would allow it to be sent to a different actor. (This supposes that take was also just isolated normally and didn’t use assumeIsolated — of course they can’t both succeed without any intervening suspension, unless something very strange is happening with custom executors.)

2 Likes

Ah, so the problem is that the assumption is that the value returned from a nonisolated function must be disconnected even if it is called from an isolated context, and I was tripped up by the example suggesting that it had to be called from a nonisolated context. I genuinely find it somewhat surprising that assumeIsolated is the only thing which breaks that assumption without explicitly unsafe operations, but I guess I don't know what else would break it.

3 Likes

I understand that the warnings described in False positive warning during the code analysis performed by SE-0430 "transferring" parameters and result values · Issue #73315 · apple/swift · GitHub are there to stay. Would you please confirm?

For example:

func schedule(_ closure: sending @escaping () -> Void) { }

func awaitCompletion(_ closure: sending @escaping () -> Void) async {
    await withUnsafeContinuation { continuation in
        // ⚠️ Task-isolated value of type '() -> Void' passed as a strongly
        // transferred parameter
        schedule {
            closure()
            continuation.resume()
        }
    }
}

func doubleSchedule(_ closure: transferring @escaping () -> Void) {
    schedule {
        // ⚠️ task-isolated value of type '() -> Void'
        // passed as a strongly transferred parameter
        schedule {
            closure()
        }
    }
}

What'd be the expected workaround for something like below?

class MyViewController: UIViewController, WKUIDelegate {
  nonisolated func webView(
    _ webView: WKWebView,
    createWebViewWith configuration: WKWebViewConfiguration,
    for navigationAction: WKNavigationAction,
    windowFeatures: WKWindowFeatures
  ) -> WKWebView? {
    return MainActor.assumeIsolated {
      WKWebView(frame: .zero, configuration: configuration)  // ❌ Sending 'configuration' risks causing data races
    }
  }
}

This works fine for Swift 5.10 with STRICT_CONCURRENCY=complete. There are some UIKit delegates that I needed to use with MainActor.assumeIsolated and are now no longer even compiling. :confused:

For implementing protocol conformances specifically, you should use @preconcurrency conformances from SE-0423: Dynamic actor isolation enforcement from non-strict-concurrency contexts. Note that there is a known issue in Xcode 16 Beta 1 where a @preconcurrency conformance does not suppress the conformance checker's Sendable checks on argument and result types, which is fixed on the release/6.0 branch.

4 Likes

This may be a dumb question, but how? As far as I know, an instance of sending T will never be part of an actor isolation region (and, if T is Sendable, then any instance of T is also a sending T, because Sendable values are always disconnected). This was suggested as a future direction in the review thread, and as far as I know, no one suggested it would be unsound:

2 Likes

I wouldn't say “here to stay” — we're certainly always interested in finding ways to remove false positives. Both of these warnings are expected, though. The problem in both of your test cases is that we can transfer closure into the closure that captures it, but we can't allow it to be transferred out of that closure. This is for the simple reason that we don't know how often the closure is called: if it's called a second time, the captures still need to exist without having been transferred away. So to remove these false positives, we would need withUnsafeContinuation to declare that its parameter function is only called once. (schedule would also need to adopt that to make the second example work.)

3 Likes

I believe that's still correct and that a sending result would be okay here. We do need to have some restrictions so that you can't merge the isolated region with the enclosing non-isolated region, though. For MainActor.assumeIsolated, this should be taken care of by the fact that the function is @Sendable; we need to check if those restrictions are in place for the general function.

1 Like

Thank you very much for your clear answer.