I wrote some code that was annotated with @MainActor
, which I now want to reuse elsewhere.
For my current testing strategy to work, it is essential that the code have the same number of executor hops as the original, fully main actor annotated code; my code depends on Task
priority to have a certain order without blocking, so a test failure isn't an infinite hang. Also, minimizing actor hops seems like a good goal on its own merits.
Here's a simple version of the code:
@TaskLocal
var local = 0
@MainActor
func withLocal(
_ i: Int,
_ fn: () async -> ()
) async {
MainActor.assertIsolated() // 1
await $local.withValue(
8,
operation: {
MainActor.assertIsolated() // 2
await fn()
}
)
}
How do we make it isolated to an arbitrary actor, now? My understanding was by using the isolated
keyword with an actor parameter, if I call this from MainActor
my assertions should not crash:
func withLocal(
_ i: Int,
isolation: isolated (any Actor)? = #isolation,
_ fn: () async -> ()
) async {
MainActor.assertIsolated() // 1
await $local.withValue(
8,
operation: {
MainActor.assertIsolated() // 2
await fn()
},
isolation: isolation
)
}
However, assertion number 2 crashes.
Upon re-reading the Swift evolution proposals, it became clear that the inheritance of actor is only for Task
; and isn't done anywhere else.
Passing fn
in directly seems to improve things a little, but my tests still wind up flaky, just somewhat less so. So there is some kind of difference between this and the explicit @MainActor
version.
Is there any way to get back the behavior of global actor annotations without committing to a specific one?
Appendix: withTaskExecutorPreference
crash
While I was delving into SEs, I found withTaskExecutorPreference
, and thought that it might work. And it (sort of) did, for my simple example. However, when I wrote something moderately more complex, it crashed:
@TaskLocal
var local = 0
func withLocal(
_ i: Int,
isolation: isolated (any Actor)? = #isolation,
_ fn: () async -> ()
) async {
final class MyTaskExecutor: TaskExecutor {
init(executor: UnownedSerialExecutor) {
self.executor = executor
}
let executor: UnownedSerialExecutor
func enqueue(_ job: UnownedJob) {
job.runSynchronously(on: executor)
}
}
MainActor.assertIsolated()
await withTaskExecutorPreference(MyTaskExecutor(executor: isolation!.unownedExecutor)) {
await $local.withValue(
8,
operation: {
await withTaskCancellationHandler {
MainActor.assertIsolated()
try? await Task.sleep(for: .seconds(1))
await fn()
} onCancel: {
}
},
isolation: isolation
)
}
}
let task = Task {
await withLocal(8) {
}
}
try! await Task.sleep(for: .seconds(0.5))
task.cancel()
Apparently there is a lock inside of Task.sleep()
, and that lock crashes, claiming to be corrupt.
I expect that I'm not supposed to be doing what I'm doing inside of MyTaskExecutor
, but I'm curious what, exactly, is incorrect about it.
As an aside, I'm not sure if this approach would ever have worked, because presumably having this executor preference on for all the operations inside this scope is probably a bad idea outside of tests, or if clearing the preference would have recreated this problem inside of the closure passed to withTaskExecutorPreference
.