My team just ran into a scenario regarding Sendable
checking that felt pretty surprising, so I wanted to confirm whether my expectations were well-founded.
We have a function isolated to a dedicated actor:
@globalActor
final class SomeDedicatedActor {
actor Actor {}
static let shared = Actor()
}
@SomeDedicatedActor
func f() async -> [Any] {
// ...
}
We typically call f()
from other functions isolated to @SomeDedicatedActor
, but we have one call that we want to make from a different actor. We make this call purely for f
's side effects, and don't care about the return result at all. I would expect that the following construction would be valid, but with Targeted/Complete Sendable
checking, we get a warning:
@MainActor
func g() async {
// â ď¸ Non-sendable type '[Any]' returned by implicitly asynchronous call
// to global actor 'SomeDedicatedActor'-isolated function cannot cross
// actor boundary
_ = await f()
}
This warning makes complete sense if we were expecting to use the result here â [Any]
isn't, and can't be, Sendable
; but we're not, and don't.
Is it reasonable to assume that Swift would elide transferring the result across actor boundaries if it won't be used?
- If so, then is this just a spurious warning, or is this construction actually unsafe right now (because the value does forcibly cross actor boundaries)?
- If not, is there a reason the result must be transferred across boundaries?
Happy to file an issue for this, but I wanted to make sure my mental model is seeing this reasonably, and there isn't anything I'm missing.
Context
f()
is a code-genned function that wraps an interface for a different language. Theoretically, we could either:
-
Write a wrapper function that throws away the result type internally:
@SomeDedicatedActor func f2() async { _ = await f() }
Or,
-
Add an annotation to the code generator to generate the equivalent of
f2()
for us
Both cases feel like overkill for what we need to do, and coming up with a reasonable name for f2()
is annoying in practice.
The Workaround
For now, the construction we're using looks like
@MainActor
func g() async {
await Task { @SomeDedicatedActor
_ = await f()
}.value
}
This is explicit, and correct to the semantics (to the best of my knowledge), but really sticks out in practice, and feels unnecessary.