Well I guess everything here works normally. That is how await works. It waits for its async friend to finish. You have this:
Task {
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
await controller.startIntro()
print("intro done")
}
This can make potentially a strong cycle cause all variables are implicitly retained inside that block. Only when Task is completed, variables will be released. If You observe better Your secondWorkAround() method, You will see that by going back in first two seconds, a retain cycle will still occur (well, view controller will be alive until SKAction doesn't end). Cause Your action is finite, thats not a biggie. But imagine if You had some sequence that runs forever.
That should be solved with [weak controller] in and after You implement that (plus remove the specific action), You will see that controller deallocates immediately after sleep is finished (after 2 seconds).
Also, doing something like scene.removeAllActions(), won't work. Cause You are removing actions that are running on scene, not on its nodes. Thus, task doesn't end when You get back to previous screen.
Now Your firstWorkAround(), well its quite hackish :) I wouldn't do that. It speeds up a scene, thus action that is run in async way from Your startIntro() method gets ended, and task is finished.
But I wouldn't go that way, it's not how is meant to work. Point is that You await the action from startIntro() to finish. That will happen if it finishes "naturally", or if You stop it. And legit way would be to find action by key and to stop it.
Note that You have something like this too:
scene.enumerateChildNodes(withName: "//*") { (node, _) in
node.removeAllActions()
}
This will go recursively trough every node and remove actions for it. You can read more here. Hope this makes sense a bit, and it helps.