Is there a way to capture a non global actor's context in a task?

So I have an actor that has a long running async stream that is used to update some of its internal state. As such, I need to weakly capture self so that I am not creating a reference cycle.

I noticed that even if I create a task within the actors isolation context, I still have to away calls to the actors own functions.

Here is an example:

actor MyActor {
    nonisolated(unsafe) private var streamTask: Task<Void, Never>?

    init() {
        Task {
            await setupStream()
        }
    }

    deinit {
        streamTask?.cancel()
    }

    private func test() {
        print("test")
    }

    private func setupStream() {
        streamTask = Task { [weak self] in
            let stream = AsyncThrowingStream {
                try await Task.sleep(for: .seconds(1))
                return Int.random(in: 0...100)
            }

            do {
                for try await value in stream {
                    await self?.test() // since this task is created in the actor's context, why do I have to await this?
                }
            } catch {
                await streamTask?.cancel() // same here
            }
        }
    }
}

Is there no way to achieve something like this:

Task { @MyActor [weak self] in
     self?.test()
}

There isn't currently a way to explicitly make a closure isolated to a particular actor. However, even if there was, the isolation analysis is not prepared to handle a weak reference to the isolated actor, because in some sense this changes the isolation of the function.

2 Likes

weak self makes it weird, so it is really hard (for better or worse, idk) tie task lifetime to actor, but you can control that from the outside:

actor MyActor {
    private var streamTask: Task<Void, Never>?

    init() {
        Task {
            await setupStream()
        }
    }

    private func test(_ i: Int) {
        print("test \(i)")
    }

    private func setupStream() {
        streamTask = Task {
            let stream = AsyncThrowingStream {
                try await Task.sleep(for: .seconds(1))
                return Int.random(in: 0...100)
            }
            do {
                for try await value in stream {
                    test(value)
                }
            } catch {
                cancel()
            }
        }
    }

    func cancel() {
        streamTask?.cancel()
        streamTask = nil
    }
}

@main
struct App {
    static func main() async throws {
        let actor = MyActor()
        Task {
            try await Task.sleep(for: .seconds(10))
            await actor.cancel()
        }
        try await Task.sleep(for: .seconds(10000))
    }
}

Note that this makes required to call cancel to avoid memory leak and stop stream.

Ah... i didnt realize it was the weak self that was causing this.

In my case, this stream would actually last the lifetime of the application, and there will only ever be one instance of this actor. So maybe its fine to strongly capture self.

https://mjtsai.com/blog/2024/03/01/where-view-task-gets-its-main-actor-isolation-from/

I believe the _inheritActorContext attribute might help. It is underscored… so it's YOLO if you want to ship on this to production.

There is a pitch for closure isolation control that includes the ability to declare an explicit isolated capture. It didn't make 6.0, but we're still pursuing it.

2 Likes