Structured concurrency and closure parameters

This works in Swift 5 but not Swift 6:

import UIKit
@MainActor func issue(stackView: UIStackView) {
    //error: Converting function value of type '@MainActor (UIView) -> Void' to '(UIView) throws -> Void' loses global actor 'MainActor'
    stackView.arrangedSubviews.forEach(stackView.removeArrangedSubview) 
}

forEach is of course

func forEach(_ body: (Path.Element) -> Void)

I am convinced that the typesystem should understand this is fine. Non-escaping parameter body can only be called on the same actor as forEach, which is known at the diagnostic to be the main actor. So this "conversion" of closure type isn't really.

I could imagine some kind of "re-actor" annotation (by analogy to rethrows) that makes the connection to forEach and its body explicit, but I think inferring it for nonescaping closures would handle most of the common cases.

FB10042197 and wwdc lab 428UYAV9D2

2 Likes

That doesn't seem strictly true; one could use withoutActuallyEscaping, send the closure to another actor, and then block until the results come back. I haven't thought through whether that can violate any of the guarantees Sendable is protecting, though.

2 Likes

one could use withoutActuallyEscaping , send the closure to another actor, and then block until the results come back.

This is a good catch. I was under the impression that this was UB, but a close reading of withoutActuallyEscaping agrees with you. Assuming we don't expand its list of UB I think there are no easy annotation-free solutions.

Whatever the solution, it certainly does seem like the code in the OP is something the language should be able to support.

1 Like

Wouldn't sendability checking prevent this? Seems like it would require the closure to be @Sendable.

You could lose the actor first, then lose the escaping (possibly in another function!)

Perhaps I'm exposing a gap in my understanding of how all the concurrency proposals fit together, but I still don't see how that lets you achieve the "send the closure to another actor" part of the story. In the example at top-of-thread, body: (Path.Element) -> Void isn't @Sendable, and withoutActuallyEscaping(_:do:) doesn't make it @Sendable, so wouldn't you keep the guarantee that the @escaping version of the closure can't cross concurrency domains within the call to forEach?

I was surprised by this as well, but it appears in practice that the closure does not have to be sendable to cross a concurrency domain. Demo

actor Illicit {
    var mutation = false
    var view: UIView
    func takeIllicitly(_ body: /* not sendable */ (UIView) -> Void) {
        //prove we are 'on' the actor
        mutation = true
        //prove we can use the closure
        body(view)
    }
    init() { fatalError() }
}

When we combine it with jrose's gadget

extension Array {
    func notForEach(_ body: (UIView) -> Void) {
        let _ = withoutActuallyEscaping(body,do: { (escapingClosure) -> () in
            let lock = DispatchSemaphore(value: 0)
            Task {
                let i = Illicit()
                await i.takeIllicitly(escapingClosure)
                lock.signal()
            }
            lock.wait()
            return
        })
    }
}

I do think this is "not how I expect Swift to work", but I am a little uncertain whether the culprit is Illicit, notActuallyEscaping, or some hypothetical annotation scheme that would make their combination illegal.

1 Like

withoutActuallyEscaping is not supposed to produce a Sendable function, so (1) capturing escapingClosure in a Sendable closure (which the Task initializer requires of its parameter) and then (2) using it as a parameter to an actor-isolated function that doesn't match the isolation of the caller are both supposed to be forbidden.

3 Likes

and it does not produce a Sendable function:

func assertSendable( body: @Sendable () -> ()) {}
func ex(body: () -> ()) {
    withoutActuallyEscaping(body) { escapingClosure in
        //Passing non-sendable parameter 'body' to function expecting a @Sendable closure
        //Parameter 'body' is implicitly non-sendable
        assertSendable(body: body)
    }
}

..but somehow it can still be moved into a Task anyway.

This is a bit puzzling to me but withoutActuallyEscaping is a compiler builtin, not a normal function, so I wonder if there is some bug in the typechecker that allows is.

What version of the compiler are you checking with?

Looks like my issue was misspelling -warn-concurrency. Swift 5.6 and 5.7 correctly diagnose notForEach in roughly the way @John_McCall expects

extension Array {
    func notForEach(_ body: (UIView) -> Void) {
        let _ = withoutActuallyEscaping(body,do: { (escapingClosure) -> () in
            let lock = DispatchSemaphore(value: 0)
            Task {
                let i = Illicit()
                //Capture of 'escapingClosure' with non-sendable type '(UIView) -> Void' in a `@Sendable` closure
                //Non-sendable type '(UIView) -> Void' passed in implicitly asynchronous call to actor-isolated instance method 'takeIllicitly' cannot cross actor boundary
                await i.takeIllicitly(escapingClosure)
                lock.signal()
            }
            lock.wait()
            return
        })
    }
}

I'm thinking the withoutActuallyEscaping trick does not actually work. In which case it should be ok to infer the original example is correct.

I looked into this in more detail. I think it is UB after all, and there's a runtime check. Demo:

actor Illicit {
    func takeIllicitly(body: () -> ()) {
        body()
    }
}
func device(body: () -> ()) {
    //error: closure argument was escaped in withoutActuallyEscaping block
    withoutActuallyEscaping(body) { escapingClosure in
        let semaphore = DispatchSemaphore(value: 0)
        do { //constrain the lifetime of Task to the minimum lifetime permitted by law
            Task {
                await Illicit().takeIllicitly(body:escapingClosure)
                semaphore.signal()
            }
        }

        semaphore.wait()
    }
}
func body() {}

func withoutActuallyUB() {
    for _ in 0..<1000 {
        print("iteration")
        device(body: body)
    }
    print("finished")
}

Task is of course

public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)

The big trouble is that opaque function Task.init may escape operation (arbitrarily, beyond the lifetime of Task and our control, etc.) Consequently it is UB for an escaping operation to capture a value from withoutActuallyEscaping.

The check is based on the lifetime of the captured closure; if you contrive it a bit further…

actor Illicit {
    func takeIllicitly(body: () -> ()) {
        body()
    }
}
class Box<T> {
    var value: T
    init(_ value) {
        self.value = value
    }
}
func device(body: () -> ()) {
    withoutActuallyEscaping(body) { escapingClosure in
        let closureBox = Box(escapingClosure as Optional)
        let semaphore = DispatchSemaphore(value: 0)
        do { //constrain the lifetime of Task to the minimum lifetime permitted by law
            Task {
                do { // ditto for the closure
                    let localClosure = closureBox.value!
                    closureBox.value = nil
                    await Illicit().takeIllicitly(body: localClosure)
                }
                semaphore.signal()
            }
        }

        semaphore.wait()
    }
}
func body() {
    print("called")
}

func withoutActuallyUB() {
    for _ in 0..<1000 {
        print("iteration")
        device(body: body)
    }
    print("finished")
}

then I think it'll pass the run-time check, and correctly too (ignoring Sendable for the time being). But I say "I think" because I didn't actually check before replying.

[EDIT: contrived a bit further to try to make lifetimes more explicit]
[EDIT: rewrote to use a class for indirection instead of a local var, and checked in Swift 5.6, which is what I have on hand today]