I have one observation that touches upon @_inheritActorContext
.
I try to make a convenience function to also understand more about currency annotations
@discardableResult
func withLifetime<Instance, OperationError, Stream>(
isolation: isolated (any Actor) = #isolation,
of object: sending Instance,
consuming stream: Stream,
@_inheritActorContext forEach operation: @Sendable @escaping (
_ object: Instance,
_ element: Stream.Element
) throws(OperationError) -> Void
) -> Task<Void, any Error>
where Instance: AnyObject,
OperationError: Error,
Stream: AsyncSequence & Sendable,
Stream.Element: Sendable
{
print(isolation) // -> output: BluetoothActor - my custom actor
return Task { [weak object] in
for try await element in stream {
guard !Task.isCancelled,
let object
else { return }
try operation(object, element)
}
}
}
If I call this function from my custom serial actor:
withLifetime(
of: self,
consuming: self.state.values
) { this, element in
print(this, element)
}
this will crash with _dispatch_assert_queue_fail
.
If will remove @_inheritActorContext
from the withLifetime
forEach
definition it won't crash but the operation
closure will run on the globalActor
:
// com.apple.root.user-initiated-qos.cooperative (concurrent)
print(this, element)
If I change the declaration of the function to (changes in comments), I get a compilation error:
Passing closure as a 'sending' parameter risks causing data races between 'isolation'-isolated code and concurrent execution of the closure
Working version but on globalActor
@discardableResult
func withLifetime<Instance, OperationError, Stream>(
isolation: isolated (any Actor) = #isolation,
of object: Instance,
consuming stream: Stream,
forEach operation: @escaping @isolated(any) ( // <-- @isolated(any) instead of @_inheritActorContext
_ object: Instance,
_ element: Stream.Element
) throws(OperationError) -> Void
) -> Task<Void, any Error>
where Instance: AnyObject & Sendable, // <-- Sendable
OperationError: Error,
Stream: AsyncSequence & Sendable,
Stream.Element: Sendable
{
print(isolation)
return Task { [weak object] in
for try await element in stream {
guard !Task.isCancelled,
let object
else { return }
try await operation(object, element)
}
}
}
On the other hand, I use the version that does not crash and compiles
@discardableResult
func withLifetime<Instance, OperationError, Stream>(
isolation: isolated (any Actor) = #isolation,
of object: sending Instance,
consuming stream: Stream,
forEach operation: @Sendable @escaping (
_ object: Instance,
_ element: Stream.Element
) throws(OperationError) -> Void
) -> Task<Void, any Error>
where Instance: AnyObject,
OperationError: Error,
Stream: AsyncSequence & Sendable,
Stream.Element: Sendable
{
print(isolation)
return Task { [weak object] in
for try await element in stream {
guard !Task.isCancelled,
let object
else { return }
try operation(object, element)
}
}
}
and I would like to enforce the isolation on which the closure should be called, I cannot also do that:
withLifetime(
of: self,
consuming: self.state.values
// Converting function value of type '@BluetoothActor @Sendable (BluetoothPeripheral, AsyncStateStream<CBPeripheralState>.Element) -> ()' (aka '@BluetoothActor @Sendable (BluetoothPeripheral, CBPeripheralState) -> ()') to '@Sendable (BluetoothPeripheral, AsyncStateStream<CBPeripheralState>.Element) -> Void' (aka '@Sendable (BluetoothPeripheral, CBPeripheralState) -> ()') loses global actor 'BluetoothActor'
) { @BluetoothActor this, element in
print(this, element)
}
I also wonder if there is a way to run the unstructured Task
with the isolation that comes from the caller so the whole for loop would run on the same isolation as operation
and from where the instance
parameters come from.