Region-based isolation, sending return value & actor isolation – what is the expected behavior?

consider the following code that is adapted from some of the examples in SE-414 (region based isolation) and SE-430 (sending parameters & results):

class NS {} // non-sendable type

actor A {
  func makeValue() -> sending NS {
    NS()
  }

  @MainActor
  func sendToMain(_ value: NS) {}

  func makeAndSend() async {
    let v = makeValue()
    await sendToMain(v)
//        |- error: sending 'v' risks causing data races
//        `- note: sending 'self'-isolated 'v' to main actor-isolated instance method 'sendToMain' risks causing data races between main actor-isolated and 'self'-isolated uses
  }
}

my interpretation of the error messages produced here suggests that the local variable v has been merged into the isolation region of the actor-instance A. thus, per the wording in the RBI doc:

When a region R_1 is merged into another region R_2 that is isolated to an actor, R_1 becomes protected by that isolation domain and cannot be passed or accessed across isolation boundaries again.

it can no longer be transferred across isolation boundaries. okay, that seems consistent with the model. however, i don't really follow precisely why v is treated as part of the actor's region in this case. the RBI doc states that the merging rules which i think apply here are:

  1. All regions of [f's non-Sendable arguments] are merged into one larger region after f executes.
  2. If any of [f's arguments] are non-Sendable and y is non-Sendable, then y is in the same merged region as [f's non-Sendable arguments]. If all of [f's arguments] are Sendable, then y is within a new disconnected region that consists only of y.

given my current understanding of the model, my best explanation of the behavior thus far is that since the method makeValue() is actor-isolated, then it implicitly has an isolated self parameter, and so upon return, the v variable is merged into the self-actor's region. this seems to be the situation outlined by this portion of the RBI doc:

The above rules are conservative; without any further annotations, we must assume:

  • In the implementation of f, any [argument] could become reachable from [any other argument].
  • [the return value] could be one of the [argument] values or alias contents of [any argument].

this seems somewhat in tension with the wording about what is described to occur when all of a function's arguments are Sendable, which presumably should be the case in this instance (either there are no arguments, or just the implicit self argument which should be Sendable). to reiterate the relevant passage from above:

If all of [f's arguments] are Sendable, then y is within a new disconnected region that consists only of y.

additionally, since the return value of makeValue() in the example has been marked sending, it should be in a disconnected region upon return. this suggests the possible explanations:

  1. there is a bug in the sending implementation and the return value is not actually disconnected upon return
  2. there is a bug in the region merging logic and v is being spuriously merged into the self-actor's region
  3. the system is working as intended

does this analysis seem accurate? any insights on this matter would be appreciated!

Motivation

4 Likes

i was thinking about this topic again while looking at this issue, and i've now distilled the crux of the question here a bit more:

can an actor-isolated method actually return a sending value?

the compiler supports the function signature, but the current implementation of the RBI logic appears to imply that if you have an actor-isolated method that has a sending return value, it ends up being merged into the actor's region, so is effectively not sending.

here is an adaptation of an example from the sending proposal illustrating the issue:

// compiled using swift 6 mode on a 6.1 development snapshot
class NonSendable {}

@globalActor
actor MyGlobalActor {
    static let shared = MyGlobalActor()
}

@MyGlobalActor
struct S {
  func getNonSendable() -> sending NonSendable {
    return NonSendable() // okay
  }
}

@MainActor
func onMain(_: NonSendable) {}

nonisolated func f(s: S) async {
  let ns = await s.getNonSendable() // okay; 'ns' is in a disconnected region

  await onMain(ns) // 'ns' can be sent away to the main actor
//      |- error: sending 'ns' risks causing data races
//      `- note: sending global actor 'MyGlobalActor'-isolated 'ns' to main actor-isolated global function 'onMain' risks causing data races between main actor-isolated and global actor 'MyGlobalActor'-isolated uses
}

by altering the getNonSendable function that returns the sending value from being main actor-isolated to being isolated to a different global actor, the compiler produces an error indicating that the return value is actually considered to be part of the global actor's region. the example from the proposal does compile, but it is presumably doing so because the actor isolation incidentally 'lines up' correctly.

@hborla and/or @Michael_Gottesman – as the resident experts on this functionality, can you shed any light on what the expected behavior here is if & when you have a moment? thanks in advance!

3 Likes

It seems there's no way to combine "actor" and "sending return". But I believe it is acceptable, because, in your example, there's no drawbacks to make makeValue nonisolated.

After all, if you want to return a sending value in a method of actor A, the value cannot be created with any non-sendable state comming from A, right?