A bug? Can't defer actor-isolated variable access

Hey, I'm seeing this issue using the compiler in Xcode Version 13.0 beta 3 (13A5192j)

class ViewController: UIViewController {
    var value : String? = "Hello"
    @IBAction func showAlert(_ sender: Any) {
        Task {
            defer { value = nil } // ← Property 'value' isolated to global actor 'MainActor' can not be mutated from this context
            // ~~await Task.sleep(1)~~ This doesn't even matter, but the await is why I'm using Task
        }
    }
}

This shouldn't be a problem, right? ViewController inherits from @MainActor UIViewController so all its properties and methods and spawned tasks should also be known to execute under the aegis of the main actor.

The async/await proposal says "A potential suspension point must not occur within a defer block." but that's not the case here since it is running on the same actor, right?

For a bit of context, I am actually awaiting on a withCheckedThrowingContinuation in order to display a file picker, and the API of the file picker doesn't allow for the continuation to be kept local to the withContinuation block. So it has to be saved as a member variable. And I reset that member to nil to indicated that the file picker is done and allow another file picker to be shown; if they try to show a picker and it isn't nil, that's an error. And I want to use defer because if the user cancel, I call the continuation to throw an error and I want the continuation member variable to be set to nil on both the happy and cancellation path.

1 Like

(moved to compiler forum, from standard library forum)

This question appears to be about using Swift, not about developing the compiler itself; I've moved it to the Using Swift forum. Thanks!

1 Like

There's a bug where we aren't recognizing defer as part of its enclosing context; it should be fixed in a later beta.

3 Likes

This hasn't been fixed in the Xcode 13 release version. Whether or not the fix will be in some upcoming point release of Xcode, is it fixed in the separate Swift compiler?

1 Like

This still seems to be an issue in Xcode 13.1.

This bug still seems to be present in Xcode 13.2 RC. Any updates? Does anyone have a link to the corresponding issue in the bug tracker?

Would be really good to get an answer on this...

defer is touted as Swift's finally:

What's the equivalent of finally in Swift - Stack Overflow
https://www.hackingwithswift.com/new-syntax-swift-2-defer

We can argue that it wasn't meant as such, but alas it is used, because there's no finally keyword in Swift.

With async/await, it's common to write code, such as:

do {
    let user = try await network.getUser(id: 1)
} catch {
    print(error)
}

The Language guide even suggests multiple catch cases:

The Swift Programming Language: Redirect

do {
    let user = try await network.getUser(id: 1)
} catch error as SomeValidationError {
    // handle validation error
} catch error as MyNetworkError {
    // handle network error
} catch {
    // handle generic error
}

This is all fine and dandy, until you add a loading indicator.

do {
    isLoading = true
    // defer { isLoading = false } // <- Does not work in Actor context
    let user = try await network.getUser(id: 1)
} catch error as SomeValidationError {
    isLoading = false
    // handle validation error
} catch error as MyNetworkError {
    isLoading = false
    // handle network error
} catch {
    isLoading = false
    // handle generic error
}

Now you're forced to either:

  • Duplicate a lot of code
  • Bundle all catch into a single one, and do the type-casting inside. (However you still need to duplicate isLoading twice, once in the non-throwing and once in the throwing branch
  • Go against Swift's error handling and make getUser(id:) non-throwing, but returning a Result

All this because you cannot use defer {} in an Actor context.

2 Likes

I ran into this earlier in the week. Still a problem in 13.2 release.

Sorry, it slipped through; I'm not sure it was ever filed as a bug. We do expect to land a fix for this in Swift 5.6.

4 Likes

I ran into this with Xcode 13.3 Beta 3 (swiftlang-5.6.0.323.60) today.

I'm seeing a build error for this on Xcode 13.3 final release as well.

Can you post the code that you expect to work?

Reproduction on Swift 5.6

swift-driver version: 1.45.2 Apple Swift version 5.6 (swiftlang-5.6.0.323.62 clang-1316.0.20.8)
Target: arm64-apple-macosx12.0
@MainActor
class Foo {
    var bar = 0
    func frob() {
        defer {
            bar += 1 // fine
        }
        Task {
            defer {
                bar += 1 // error: property 'bar' isolated to global actor 'MainActor' can not be mutated from a non-isolated context
            }
            bar += 1
        }
    }
}

I think it has to be:

Task { @MainActor in

instead, doesn't it? Though, it still shows the same error.

The actor context is inherited in this case, so the explicit annotation makes no difference.

I'm seeing the issue directly inside a Task initializer closure as well. I noticed the Changelog (swift/CHANGELOG.md at main · apple/swift · GitHub) uses an example inside an async function - perhaps the Task use case isn't being handled properly?

I've had this issue as well, and I agree I think it boils down to how this is checked within closures, while in functions it seems to work.

@MainActor func onMain() {}

// doesn't compile
Task { @MainActor in
	defer { onMain() }
}

// compiles
Task { @MainActor in
	@MainActor func _nested() {
		defer { onMain() }
	}
	
	_nested()
}

Another Example to confirm that its related to closures:

@MainActor
class A {
  var isLoading: Bool = false

  func startTask1() {
    Task { @MainActor in
      isLoading = true
      defer { isLoading = false } // ❌ Error: Property 'isLoading' isolated to global actor 'MainActor' can not be mutated from a non-isolated context
      print("Doing task")
    }
  }

  // Extracting the logic to a separate function
  func startTask2() {
    Task { await startTask2Impl() } // ✅ OK
  }

  private func startTask2Impl() async { 
    isLoading = true
    defer { isLoading = false } // ✅ OK
    print("Doing task")
  }
}
1 Like

I'm also having the same issue. Here's my use case:

@MainActor
public final class ViewModel: ObservableObject {
    private let app: App
    
    @Published
    public var isLoading: Bool = false

    @Published
    public var errorMessage: String? = nil

    public init(app: App) {
        self.app = app
    }

    public func load() {
        isLoading = true
        
        Task {
            defer {
                isLoading = false
            }
            
            do {
                try await app.load()
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
}