New Swift 6.3 isolation warnings with `@Sendable` × `@escaping`: escape hatch?

I recently opened up my team's project in the Xcode 26.4 betas to test something out, and ran into some new concurrency warnings in existing code. Most were plausible, and were resolved by marking some async functions with nonisolated(nonsending), but there's one case that I'm having a hard time resolving. Simplified sampler reproducer:

// This is a class I don't own from a system SDK; the class is originally Obj-C,
// but annotated for Swift.
class External {
    // The `@escaping` annotation is correct, but I _believe_ `@Sendable` is not;
    // the block, by definition, is called on the caller's thread.
    static func ƒ1(_ work: @escaping @Sendable () -> Void) {
        work()
    }
}

// This is a class defined in the project.
@MainActor
final class Internal {
    func ƒ2(_ completion: @escaping () -> Void) {
        // In the real code, `completion` does escape, but is also called on the
        // caller's thread.
        completion()
    }

    func ƒ3() {
        Task { @MainActor in // just to be explicit
            var complete = false
            External.ƒ1 {
                MainActor.assumeIsolated {
                    self.ƒ2 {
                        MainActor.assumeIsolated { // just to be explicit
                            complete = true // ⚠️ Sending 'complete' risks causing data races; this is an error in the Swift 6 language mode
                                            // Task-isolated 'complete' is captured by a main actor-isolated closure. main actor-isolated uses in closure may race against later nonisolated uses
                        }
                    }
                }
            }

            print(complete)
        }
    }
}

(I'm aware that in this sample code, ignoring the dummy implementations of ƒ1 and ƒ2, it appears that complete won't be read correctly; in reality, the method is much more complicated, but we validly await and synchronize on the value.)

Some detail:

  1. This reproduces only with Swift 6.3 in a project with nonisolated default isolation and concurrency checking set to "Complete"; earlier Swift versions, default MainActor isolation, or minimal/targeted concurrency checking don't produce the warning
  2. Dropping either @Sendable from External.ƒ1() or @escaping from Internal.ƒ2() resolves the warning
  3. The type of complete in practice is Sendable anyway, but for good measure, this also reproduces with Atomic/Mutex

Is this warning legit, or a known issue? It appears spurious to me (since complete is both Sendable and only accessed on the MainActor), but I'd love for an expert to confirm.

  1. If spurious, is there a way to even more clearly indicate to the compiler that what we're doing here is safe? (I know I can turn down concurrency checking in the project to silence the warning, or write an Obj-C wrapper for the external type whose block isn't marked @Sendable, but I'd rather avoid those if there's something semantically correct)
  2. If legitimate, what's the right way to express this operation?
1 Like

From a quick glance, complete is captured by reference. First, it’s declared in a MainActor context, then transferred to a nonisolated context (by reference). Even if it’s safe in this case, the error is still correct. However, if you want to manage the risk yourself, you could use nonisolated(unsafe) or an @unchecked Sendable box.

1 Like

Ah, I think I understand now. Even though the usage of complete is entirely contained to @MainActor code, the passage through a nonisolated context requires then implicitly sending the value back to the MainActor, and the semantics of sending require static verification that the value can no longer be used from the origin context, but this can't be done for nonisolated contexts; is that right?

If so, it seems sensible to warn for a capture of a mutable var like this. Though unfortunately, the warning is also produced for values wrapped in Atomic/Mutex, which cannot be misused in this way. Applying nonisolated(unsafe) to Atomic/Mutex allows the code to compile, but does then warn that

⚠️ 'nonisolated(unsafe)' is unnecessary for a constant with 'Sendable' type 'Atomic<Bool>', consider removing it

if you're working with a global actor and can tolerate the overhead, you could i think wrap the value in your own reference type wrapper with matching isolation, e.g. in this example something like:

@MainActor
final class Workaround<T> {
    var value: T
    init(_ v: T) { self.value = v }
}

and then use that instead of the synthesized box that you get for a closure capture of a mutable var.

personally i think John's advice here is applicable – this is a limitation of the conservative rules of the region isolation analysis implementation. the code you have is actually safe, so using an escape hatch like nonisolated(unsafe) seems reasonable to me (though it might expose you to more refactoring risk).

this is surprising... do you have a sample that reproduces this behavior (i've been unable to replicate it myself)?

1 Like

Yeah, that's fair! And likely the way to go for this specific function in the short term.

Just replacing var complete = false with let complete = Atomic(false) or let complete = Mutex(false) in the code sample above (adjusting the usage sites to load/store/withLock as needed) reproduces this for me. (Using both Xcode 26.4 beta 1 and beta 2)

I can’t reproduce it in the nightly build on Godbolt either, but I can with the devsnapshot:

1 Like

This is suspect to me ;)

In general, there's no mechanism whereby an escaped closure could be re-scheduled for execution on an arbitrary thread.

There are some likely options for ObjC code to be using:

  • it uses dispatch_async to a custom or global queue (@Sendable is correct)
  • it uses dispatch_async to the main queue (@Sendable is correct but it should also have @MainActor)
  • the closure doesn't actually escape, and is called synchronously within the body of the function (@escaping is wrong and @Sendable is unnecessary)
  • it must be called on a thread with a runloop, and uses that runloop to schedule the callback (pretty unlikely, but this is the only case I can think of where you're right that @escaping would be correct and @Sendable would not)

Indeed, External.ƒ1 is Timer.scheduledTimer(withTimeInterval:repeats:block:) :smile:

I think in this case it's reasonable to file a bug against Foundation; the new conservative "mark closures as @Sendable" policy will be right most of the time, but runloop-related stuff is a strange exception to the rule. It makes sense that Timer should be imported correctly.

The function should probably be marked @available(*, noasync) at the same time, since I hate to think what would happen if somebody tried to run a runloop on a concurrency-pool thread :anxious_face_with_sweat:

1 Like

Yeah, this is a bit of a weird case where the block for Timer.init(timeInterval:repeats:block:) does need to be marked @Sendable (since the timer can be scheduled on any runloop), but if Timer.scheduledTimer(withTimeInterval:repeats:block:) takes a non-@Sendable block, it effectively can't be implemented as

class func scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void) {
    let timer = Timer(timeInterval: interval, repeats: repeats, block: block)
    RunLoop.current.add(timer, forMode: .default)
}

Unlikely to be a problem in Obj-C, but would impact a pure Swift implementation (you'd need at least a private initializer that takes a non-@Sendable block, but Timer is also an open class, so definitely not straightforward).

Oh, you'd hate to read what the rest of the function does, then...

Apropos of nothing...
  • RunLoop is by design re-entrant, and if you're very careful, it is both safe and well-defined to "block" a thread on its own runloop by manually spinning it internally, allowing other work to continue on the same thread
  • The main executor on Apple platforms is a wrapper for the main runloop such that spinning the main runloop allows you to cooperate with the concurrency runtime, keeping the thread available for @MainActor tasks
  • If you find yourself in the unenviable position of needing to bridge between execution contexts you don't control, it is still possible to "block" a synchronous non-concurrency thread with work done on and off the main actor while keeping everything running smoothly (beware nasal demons, etc.)

In any case, I think nonisolated(unsafe) is likely the way to go (unless the fix for Atomic/Mutex lands in the final Xcode 26.4 Swift version), until the greater context for this code can be migrated on to a better solution.

Thanks all for the pointers!

i took the liberty of reporting this as a regression: [6.3] seemingly spurious RBI diagnostic regressions involving Mutex/Atomic · Issue #87523 · swiftlang/swift · GitHub. since it does not appear to occur in 6.2 or on main, perhaps there is something that can be applied to the 6.3 branch that will resolve it if that ship has not yet fully sailed. (FYI @hborla @Michael_Gottesman).

2 Likes

Thanks, @jamieQ!

1 Like

A Swift implementation could just unsafeBitCast it to @Sendable and call the other initializer, knowing that that'll be safe since it won't actually be sent.

Unless you can unschedule the timer from this runloop, then later schedule it on another runloop, in which case the @Sendable here is unfortunately correct…

(edit) Urgh, there's some awkwardness here; Timer isn't Sendable so you might think that's not possible and that's the end of it. But RunLoops are tied to threads, and Swift Concurrency isn't. So I suspect there's edge cases where a Timer that hasn't been sent, could theoretically end up scheduled on multiple runloops. And IDK if that's a violation of [Core]Foundation preconditions, or if that's "supposed" to work. And if it is supposed to work, then even though you didn't send the timer, the fire callback might be called in a different isolation. Yay.

(edit 2) This is what I mean when I tell people that Timer is dead in Swift 6. It needs a big deprecation notice slapped on it ASAP so people stop looking at it, and start looking at swift-async-algorithms' AsyncTimerSequence instead!

could theoretically end up scheduled on multiple runloops

I don’t think that could ever work.

Apple platforms implement run loop as a Mach port set and timer as a Mach port, and a port can’t be a member of multiple port sets [1].

A run loop source can be scheduled in multiple modes on the same run loop, but that’s not the same thing.

This is what I mean when I tell people that Timer is dead in Swift 6.

Indeed.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

[1] It took me a while to confirm that, but here it is:

If the receive right is already a member of any other port sets, it is removed from those sets first.

Ignoring all the details about synchronization and run loops and just trying to deal with this as it is, I believe we could reasonably make Swift allow this pattern. The problem is simply that Swift is treating a capture in a closure with different isolation as a send, when really we ought to be reasoning about the isolation of the functions the captured variable is used. This is a distinction that only matters with indirect captures, which is to say, when a variable is captured in a closure only because it is used in a further-nested closure. Because all of the contexts that actually access the captured variable share the same static isolation, it is straightforwardly true (in a way that Swift ought to be able to reliably prove) that none of those accesses can race even though the variable is also captured (but not accessed) by functions with a different static isolation.

Note that this would be more problematic if the captured variable had a non-Sendable type because of the deinit problem, although in this case Swift could simply force the capture to be destroyed on the right isolation.

3 Likes