Non-sendable result type cannot be sent from nonisolated context in call to instance method

Hi all,

I am struggling to identify what is the best solution to pass non-sendable from nonisolated context to a call which is nonisolated but hold by an isolated object.

import SwiftUI
import PlaygroundSupport

// We cannot and do not want to conform it to Sendable
class NonSendable {
    var name: String

    init(name: String) {
        self.name = name
    }
}

protocol Servicing {
    func longOP(model: NonSendable) async -> NonSendable
}

final class Service: Servicing {
   func longOP(model: NonSendable) async -> NonSendable {
       try? await Task.sleep(nanoseconds: 1_000_000) // Assume this takes a long time and is synchronous

       model.name = " serviced"

       return model
   }
}

@MainActor
@Observable class Store {
    var model: NonSendable?
    let service: Servicing = Service()

    func loadModel(named name: String) async {
        model = NonSendable(name: name)
        await service.longOP(model: model!) // Error: Non-sendable result type 'NonSendable' cannot be sent from nonisolated context in call to instance method 'longOP(model:)'
    }
}

struct ContentView: View {
    @State private var store = Store()

    var body: some View {
        Text("hello \\(store.model?.name)!")
            .frame(width: 600)
            .task {
                await store.loadModel(named: "friends")
            }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

A solution: Making Servicing isolated to the MainActor

But why Service/Servicing should be isolated to the MainActor? It could also have some parameters which have to work on a nonisolated context, so only forwarding the problem to the next object.

@MainActor
protocol Servicing {
    func longOP(model: NonSendable) async -> NonSendable
}

final class Service: Servicing {

   func longOP(model: NonSendable) async -> NonSendable {
        async let result = longOP()
        model.name += await result
        return model
    }

    private nonisolated func longOP() async -> String {
        try? await Task.sleep(nanoseconds: 3_000_000_000) // Assume this takes a long time and is synchronous
        return "serviced"
    }
}

I did try to use the sending keyword, or isolation splitting but I could not find a solution with them.

What would be the best approach to solve that?

What about:

  1. @MainActor class NonSendable {...} If you are using it for UI, then you'd only want to access it there.
  2. protocol Servicing: Sendable {...} This way you tell the compiler it's safe to send from your MainActor isolation in Store to a nonisolated context. Which makes sense for a service I think.

As written, your implementation of Servicing is implicitly Sendable since it has no mutable state. If you do want mutable state and still reference semantics, I would implement Servicing with an actor.

Edit: Here is an example that uses #isolation, but it has a drawback -- your calls to service wont leave the actor they are being made on (i.e. main actor here), so if service is intended to do background work, this is not ideal.

import SwiftUI

class NonSendable {
  var name: String

  init(name: String) {
    self.name = name
  }
}

protocol Servicing {
  func longOP(
    model: NonSendable,
    isolation: isolated (any Actor)?
  ) async -> NonSendable
}

final class Service: Servicing {
  func longOP(
    model: NonSendable,
    isolation: isolated (any Actor)?
  ) async
    -> NonSendable
  {
    try! await Task.sleep(for: .milliseconds(100))
    model.name += " serviced"
    return model
  }
}

@MainActor
@Observable class Store {
  var model: NonSendable?
  let service: any Servicing = Service()

  func loadModel(named name: String) async {
    model = NonSendable(name: name)
    // Executes on main actor, so we are no longer sending anything.
    _ = await service.longOP(model: model!, isolation: #isolation)
  }
}

struct ContentView: View {
  let store = Store()
  var body: some View {
    Text("hello \(store.model?.name ?? "unknown")")
      .frame(width: 600)
      .task {
        await store.loadModel(named: "friends")
      }
  }
}

There is currently proposal to make the above example default behavior SE-0461, i.e. since longOp is a nonisolated async function it would just run on the main actor by default when you call it in loadModel(named:) without any need for #isolation.

1 Like

Thank you for your insight @jameesbrown,

  1. Yes it would make sense as in this case it is used for the UI, but in some cases it is hard to change, and surprisingly Apple does not do that in their documentation. I am not sure then if adding @MainActor is an anti-pattern or not.
  2. It makes also sense, but it does not solve the error at the end, so we really need to pass the isolation context like in your example or make it MainActor.

I did some tries before with isolated (any Actor)? but I discarded it because it seemed too verbose, but after reading the discussions and the proposal you linked, it seems to be a good direction.

your calls to service wont leave the actor they are being made on (i.e. main actor here), so if service is intended to do background work, this is not ideal.

Normally they should leave the current context in the call to the nonisolated function.

So for now with the assumption to keep the NonSendable object not isolated to the MainActor.

protocol Servicing: Sendable {
    func longOP(
        model: NonSendable,
        isolation: isolated (any Actor)?
    ) async -> NonSendable
}

final class Service: Servicing {
    func longOP(
        model: NonSendable,
        isolation: isolated (any Actor)?
    ) async -> NonSendable {
        async let result = longOP()
        model.name += await result

        return model
    }

    private nonisolated func longOP() async -> String {
        try? await Task.sleep(nanoseconds: 3_000_000_000) // Assume this takes a long time and is synchronous
        return " serviced"
    }
}

And if the proposal SE-0461 is implemented, it will be something like:

final class Service: Servicing {
    func longOP(model: NonSendable) async -> NonSendable {
        async let result = longOP()
        model.name += await result

        return model
    }

    @execution(concurrent)
    private nonisolated func longOP() async -> String {
        try? await Task.sleep(nanoseconds: 3_000_000_000) // Assume this takes a long time and is synchronous
        return " serviced"
    }
}

It looks a bit better and less verbose...

1 Like

I came up with a version that avoids making Model MainActor while allowing the pattern you showed earlier, however it requires use of Mutex if you are to have mutable state in Service. It should work with an actor also, but for some reason it doesn't.

import Foundation
import Synchronization

protocol Servicing: Sendable {
 func fetchModel(_ id: Int) async -> sending Model
}

final class Service: Servicing {
  let dataStore: Mutex<[Int: Model]>
  init(models: sending [Int: Model]) {
    self.dataStore = .init(models)
  }
  func fetchModel(_ id: Int) async -> sending Model {
    let found = dataStore.withLock(\.[id]) ?? .init(id: id)
    // Make a deep copy of model to avoid sending our mutable state.
    let copy = Model(id: id)
    copy.data = found.data
    return copy
  }
}

@Observable
class Model {
  let id: Int
  var data: Int
  init(id: Int) {
    self.id = id
    self.data = 0
  }
}

@MainActor
@Observable
class Store {
  let service = Service(models: [0: .init(id: 0), 1: .init(id: 1)])
  var model = Model(id: 0)
}

let store = Store()
let changed = Mutex(false)
withObservationTracking({
  _ = store.model
  Task { @MainActor in
    let model = await store.service.fetchModel(10)
    store.model = model
  }
}, onChange: {
  changed.withLock({ $0.toggle() })
  print("changed")
})

while !changed.withLock(\.self) {
  await Task.yield()
}
print(store.model.id)

If you have a non-Sendable object, and if longOP mutates the object, you want to make sure you do not cross actor boundaries. So, if your NonSendable object is on the main actor, you’d want to make sure that this mutation in longOP happens on the main actor, too, to avoid sending the NonSendable across actor boundaries.

Another option is to declare longOP to inherit the actor isolation of the caller (by virtue of SE-0420):

class Service {
    func longOP(isolation: isolated (any Actor)? = #isolation, model: NonSendable) async -> NonSendable {
        try? await Task.sleep(nanoseconds: 1_000_000) // Assume this takes a long time and is synchronous

        model.name = " serviced"

        return model
    }
}

Then you can do things like:

@MainActor
@Observable class Store {
    var model: NonSendable?
    let service = Service()

    func loadModel(named name: String) async {
        model = NonSendable(name: name)
        let nonSendable = await service.longOP(model: model!)
        print(nonSendable)
    }
}

Now, in the above, I retired the Servicing protocol. We can reintroduce that, but the protocol doesn't allow you to specify the default value, so you have to manually declare it at the call point. E.g.:

protocol Servicing {
    func longOP(isolation: isolated (any Actor)?, model: NonSendable) async -> NonSendable
}

class Service: Servicing {
    func longOP(isolation: isolated (any Actor)? = #isolation, model: NonSendable) async -> NonSendable {
        try? await Task.sleep(nanoseconds: 1_000_000) // Assume this takes a long time and is synchronous

        model.name = " serviced"

        return model
    }
}

@MainActor
@Observable class Store {
    var model: NonSendable?
    let service: Servicing = Service()

    func loadModel(named name: String) async {
        model = NonSendable(name: name)
        let nonSendable = await service.longOP(isolation: #isolation, model: model!) // note we had to manually specify the isolation
        print(nonSendable)
    }
}

Note, the above requires Swift 6, so you might not be able to use it in a playground. You may have to use Xcode 16.2 or later.


FWIW, you asked about using sending keyword. That works in cases where the method creates and returns a non-Sendable object (e.g., fetches it from some remote service). That works in many cases where you want a non-Sendable to be passed from one asynchronous context to another, but in your example, longOP is taking an object, mutates it, and returns it, which will not be able to take advantage of this region-based isolation of sending.