Why Does the Compiler Allow Static Properties in Actors Without await?

Hi everyone,

I’ve been working on a testing setup where I encountered some compiler behaviour that has me puzzled. Specifically, it relates to how async/await interacts with static properties in actors. I’m hoping to get a deeper understanding of why this behaviour is considered valid.

Here’s the relevant snippet:

// ensures tests are executed serially, protecting the shared static var
@globalActor
actor TestActor {
    static let shared = TestActor()
}

@TestActor
@Suite("KeychainKeys Tests") struct KeychainKeysTests {
    
    // making this an actor to keep the compiler happy, even though this
// will only be used within the TestActor...
    actor MockBundle: XcodeBundle {
        static var identifier = ""
        
        static func extractIdentifier() throws -> String {
            return identifier
        }
    }

    @Test("Valid Bundle Identifier")
    func testEncryptionKeyWithValidIdentifier() {
        /// Given
// are we not crossing an actor boundary here? MockBundle actor <> TestActor
        MockBundle.identifier = "com.example.app"

        /// When
        let key = KeychainKeys<MockBundle>.encryptionKey

        /// Then
        let expectedHash = SHA256.hash(data: Data("com.example.app".utf8))
            .map { String(format: "%02x", $0) }.joined()
        #expect(key == expectedHash, "Encryption key should match the expected hash.")
    }
}

My Question

In the above example:

• MockBundle is an actor.

• identifier is declared as a static var in the actor.

My understanding (correct me if I'm wrong here):

  1. Static properties are not actor-isolated: This means they’re not protected by the actor’s isolation model.
  2. Static mutable state is inherently global mutable state: The compiler treats MockBundle.identifier as potentially unsafe in concurrent contexts, which is why it enforces concurrency safety checks (e.g., requiring MockBundle to be an actor).

This feels like a “maybe-safe” scenario, where the compiler is satisfied as long as the actor declaration is present, but it doesn’t guarantee thread safety for the static var.

What I’d Like to Understand

Why does the compiler enforce that MockBundle must be an actor to handle a static var**, but then allow direct access to that** static var without await**?**

Is this a deliberate design choice, or is there room for improvement in how Swift enforces safety in this situation?

Should static properties in actors behave differently to align more strictly with the actor isolation model?

I appreciate that the actor model and async/await have strict rules for enforcing concurrency safety, but this particular case feels like a grey area to me. If anyone could clarify the reasoning behind this behaviour, I’d be very grateful!

Thank you in advance for your insights.

1 Like

i'm fairly sure this is a bug. the example you have can be reduced to something like:

@main
struct S {

    actor A {
        static var mutableStatic = ["1"]
    }

    static func main() async {
        print("start")
        do {
            async let _ =  {
                for _ in 1...100 {
                    A.mutableStatic.append("2")
                }
            }()
            async let _ = {
                for _ in 1...100 {
                    A.mutableStatic.append("3")
                }
            }()
        }
        print("done")
    }
}

and if you run this after having built it with thread sanitizer enabled[1], you'll get a runtime data race safety violation diagnostic.

changing from an actor to a class, marking the variable with a global actor annotation (or trying to explicitly mark it nonisolated), or moving the variable to be a mutable global all produce the expected compiler diagnostics about potentially unsafe concurrent access.

i encourage you to file a bug if & when you have a chance.


  1. swiftc <example>.swift -sanitize=thread -parse-as-library -swift-version 6 ↩︎

3 Likes

With the caveat that I am not certain about any of these answers...

Why does the compiler enforce that MockBundle must be an actor to handle a static var**, but then allow direct access to that** static var without await**?**

Does the XcodeBundle protocol require it to be an actor? If not, then can you change it to static let? Does it need to be mutable?

There is also an implicit question here of "Why does the compiler allow the static var to be mutable"; I believe it may inherit the actor isolation of @TestActor from KeychainKeysTests, but I'm not sure.

Is this a deliberate design choice, or is there room for improvement in how Swift enforces safety in this situation?
Should static properties in actors behave differently to align more strictly with the actor isolation model?

My understanding is that actor instances protect mutable state. e.g., In order for static vars to be protected actor state, we would need to spin up an arbitrary instance of an actor, which may not always be statically possible.

Consider this example from the actors language proposal:

actor MyActor {
  let name: String
  var counter: Int = 0
  func f()
}

extension MyActor {
  func g(other: MyActor) async {
    print(name)          // okay, name is non-isolated
    print(other.name)    // okay, name is non-isolated
    print(counter)       // okay, g() is isolated to MyActor
    print(other.counter) // error: g() is isolated to "self", not "other"
    f()                  // okay, g() is isolated to MyActor
    await other.f()      // okay, other is not isolated to "self" but asynchronous access is permitted
  }
}

An actor can access its own state synchronously, but cannot access another actor's state synchronously, as each actor instance has its own 'async boundary'.

Hope that helps!

3 Likes

Great guesses here, but we can test this by cutting inference short.

@globalActor
actor TestActor {
    static let shared = TestActor()
}

@TestActor
struct MyStruct {
    actor MyActor {
        static var identifier = ""
    }
    
    nonisolated func a() {
        print(MyActor.identifier)
    }
}

This compiles error-free. I'm pretty sure this is indeed a bug with static properties of actors. It actually shouldn't matter at the points of access anyways - the problem is at the definition within MyActor.

(I tried with the 6.1 snapshot too just for kicks, and also does not produce an error)

In fact, we can get even simpler. I see now way this code could be safe, but it compiles.

actor MyActor {
    static var identifier = ""
}

nonisolated func a() {
    print(MyActor.identifier)
}

Static properties/methods of actors are no different than static properties/methods of other types; as @JuneBash mentioned above, actor isolation is based around having some instance of the actor. From SE-0306:

By default, the instance methods, properties, and subscripts of an actor have an isolated self parameter. This is true even for methods added retroactively on an actor via an extension, like any other Swift type. Static methods, properties, and subscripts do not have a self parameter that is an instance of the actor, so they are not actor-isolated.

So they don't get any isolation from being declared on the actor, but if they aren't being diagnosed when they're potentially being mutated concurrently, that may be a bug.

4 Likes

Yeah, you're right; this does seem to be an unsafe bug. Running this code in a fresh executable package...

actor MyActor {
    static var value = 0
}

nonisolated func a() async {
  await withTaskGroup(of: Void.self) { taskGroup in
    for _ in 0..<1000 {
      taskGroup.addTask {
        MyActor.value += 1
      }
    }
  }
  print(MyActor.value)
}

...compiles and runs without warnings, but does not deterministically print 1000 like one would expect. This seems to me like unsafe, undefined behavior and a bug.

5 Likes

And to add to this, all it takes is changing actor MyActor to class MyActor to get the compiler to diagnose it (with -swift-version 6). Since static data is really just global data, it should be diagnosed the same way whether it's on an actor or some other type.

4 Likes

Thank you all.
I have raised this as a suggested improvement instead of a bug.

1 Like