Why can't I store this `sending` parameter in my mutex?

Can anyone shed some deeper light on why the following code doesn't compile?

import Foundation
import Synchronization

final class A {
    let state = Mutex(State())
    
    struct State {
        var callbacks: [UUID: () async -> ()] = [:]
    }
    
    func a(_ callback: sending @escaping () async -> ()) {
        state.withLock {
            $0.callbacks[UUID()] = callback // 'inout sending' parameter '$0' cannot be task-isolated at end of function
        }
    }
}

The error is:

'inout sending' parameter '$0' cannot be task-isolated at end of function

My evidently incomplete mental model is that the closure parameter is statically known to be "disconnected" (by virtue of the sending keyword), and that this should mean that passing the closure into the mutex-protected state does not infect the state with another isolation region. Rather, the disconnected closure gets absorbed into the isolation region of the state. What am I missing?

1 Like

I think your mental model is right (either that or ours are both wrong in a similar way :sweat_smile:). I think it is a bug that this does not work (exhibits A, B, C). Speculating a bit, but I think it may be due to closure captures generally not getting annotated as sending when lowered to SIL (with stuff involving async let being a possible exception).

1 Like

The specific limitation there is that you can't do things in a closure that can only safely be done once, like transferring a value into a disconnected region. We'd have to know how often the closure is called.

6 Likes

Exactly, the same issue I ran into while trying to replace my custom lock implementation with Mutex. While doing so, I also discovered a race condition involving closures that return a non-Sendable value and are not marked as sending.

1 Like

@NotTheNHK, I think the issue in OP's example wasn't caused by sending return value, because it can be reproduced with simpified code below.

struct State {
    var callback: () async -> ()
}

func foo(_ fn: (inout sending State) -> Void) {}

struct S {    
    func test(_ callback: sending @escaping () async -> ()) {
        foo { 
            $0.callback = callback 
        } // error: 'inout sending' parameter '$0' cannot be task-isolated at end of function [#RegionIsolation]
    }
}

I agree with OP and @jamieQ that the code should compile. ~IMO "task-isolated" in the diagnostic is incorrect, as demonstrated in the following code. It produce similar diagnostic, but I believe the local variable callback is in disconnected region, not task-isolated.~

class NS {}

struct State {
    var callback: NS
}

func foo(_ fn: (inout sending State) -> Void) {}

struct S {    
    func test() {
        let callback = NS()
        foo {
            $0.callback = callback 
        } // error: 'inout sending' parameter '$0' cannot be task-isolated at end of function [#RegionIsolation]
    }
}

As the above example doesn't use closure, it also indicates that the issue isn't specific to closure. I found @John_McCall's explanation very helpful (IIUC what he said is a general rule and applies to both sending and non-sending closures), but I suspect it doesn't apply to OP's example.

PS: IIUC a sending value or closure shouldn't be transferred inside a closure, but it should be OK to access the value or call the closure in another closure.
class NS { var value = 0 }

func outer() async {
    let ns = NS()
    await middle { ns.value += 1 }
}

func middle(_ fn: sending @escaping () async -> ()) async {
    await inner { await fn() } // sending closure is wrapped in another sending closure
}

func inner(_ fn: sending @escaping () async -> ()) async {
    await fn()
}

Sorry, my mistake. The diagnostic is correct. The value is task-isolated inside the closure. So I think the OP's example doesn't work because of inout sending parameter's restriction, which applies to both captured class instances and closures.

        foo {   // <- these braces delimit a closure expression
            $0.callback = callback
        }

The compiler is giving a somewhat confusing diagnostic here, but it is correct that this code cannot be allowed to compile absent whole-program information. callback cannot be transferred into $0.callback because it remains referenced by the closure capture. If foo called the closure twice with two different variables, callback could end up referenced by two different regions simultaneously.

1 Like