Async assignment to a lazy local var causes Xcode 15.3 compiler crash

[Please skip to the bottom of the thread - the cause of the compiler crash is assigning an async value to a local lazy var]

I am working on an app which communicates between multiple network devices simultaneously, mainly using the Network and CoreBluetooth frameworks which are both pre-concurrency.

I have frequently encountered a situation in my async code where I need to suspend, waiting for some other piece of code to be in a certain state, and/or monitor state changes.

After lots of hair pulling and misdirection, I had settled on the following actor which I use in multiple places, to arbitrate such exchanges between contexts. It encapsulates the state value and an AsyncStream of those values as they change, but limits access to the stream to one context at a time to avoid the unwritten restriction that AsyncStreams cannot be iterated over by multiple contexts simultaneously. (As a side observation, I was surprised that I was able to create multiple iterators on the stream from different contexts without any runtime errors, but I found the behavior unpredictable).

actor AsyncState<State> where State: Equatable {

    private(set) var value: State {
        didSet {
            if oldValue != value {
                print("Scan State: \(String(describing: self.value))")
                stateContinuation?.yield(value)
            }
        }
    }

    private lazy var stateUpdates: AsyncStream<State> = {
        AsyncStream { (continuation: AsyncStream<State>.Continuation) -> Void in
            self.stateContinuation = continuation
        }
    }()
    private var stateContinuation: AsyncStream<State>.Continuation?


    init(state: State) {
        self.value = state
    }

    var streamSubscribed = false

    func obtainSubscription() -> AsyncStream<State> {
        if streamSubscribed { fatalError("Subscribing to AsyncState<\(State.Type.self)> stream multiple times")}
        streamSubscribed = true
        return stateUpdates
    }

    func relinquishSubscription() {
        streamSubscribed = false
    }

    func change(to newState: State) {
        let oldState = self.value
        if oldState == newState { return }
        print("Change State: from: \(oldState) to: \(newState)")
        self.value = newState
    }
}

Now this had been pretty successful until this morning when I managed to crash Xcode 15.3 with the following (simplified) code:

class Connection {

    enum State {
        case waitingToConnect, readyToPair, communicating, pairing, paired, unavailable, disconnected
    }
    enum MyError: Error  {
        case notConnected
    }

    private let state = AsyncState<State>(state: .waitingToConnect)

    func waitTillPaired() async throws {

        var state: State? = await self.state.value

        // *** swift-frontend crashes on the following line 26 ***
        lazy var stateChanges = await self.state.obtainSubscription().makeAsyncIterator()
        defer {
            Task {
                await self.state.relinquishSubscription()
            }
        }

        repeat {
            guard state != .unavailable else { throw MyError.notConnected }
            if state == .paired {
                return
            }
            state = await stateChanges.next()
        } while state != nil
        throw MyError.notConnected
    }
}

With just the above in a fresh project, Xcode crashes with the following:

1.	Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
2.	Compiling with the current language version
3.	While evaluating request IRGenRequest(IR Generation for file ".../TestAsyncState/Connection.swift")
4.	While emitting IR SIL function "@$s14TestAsyncState10ConnectionC14waitTillPaired12reachability7timeoutyAA0bC0CySbG_SdtYaKF12stateChangesL_ScS8IteratorVyAC0C0O_Gvg".
 for getter for stateChanges (at .../TestAsyncState/Connection.swift:26:18)

I will report the error on Feedback Assistant, but am writing here to ask whether anyone thinks I am misusing the concurrency system, or if there is a better/cleaner/safer way to achieve the same outcome - i.e an ability to check, monitor or wait for a state change asynchronously.

you’re getting a compiler crash, which means it’s probably triggered by the shape of the code instead of the specific types in it. do you still get the crash when you replace the types with local substitutes?

Good call. If I replace all references to AsyncStream with MyAsyncStream defined as follows, I still get the compiler crash. If I change AsyncState from actor to class then the code compiles (but is of course no longer thread safe).

public struct MyAsyncStream<Element> {

    public struct Continuation : Sendable {
        public enum YieldResult {
            case enqueued(remaining: Int)
        }

        @discardableResult
        public func yield(_ value: Element) -> MyAsyncStream<Element>.Continuation.YieldResult {
            return .enqueued(remaining: 0)
        }
    }

    public struct Iterator : AsyncIteratorProtocol {
        public mutating func next() async -> Element? {
            return nil
        }
    }

    public init(_ elementType: Element.Type = Element.self, _ build: (MyAsyncStream<Element>.Continuation) -> Void) {
    }

    public func makeAsyncIterator() -> MyAsyncStream<Element>.Iterator {
        return Iterator()
    }
}

Further whittling down, the compiler crash still occurs if waitTillPaired() is simplified to:

    func waitTillPaired() async throws {
        lazy var stateChanges = await self.state.obtainSubscription().makeAsyncIterator()
        let state = await stateChanges.next()
    }

The code compiles correctly if I remove lazy or if I change AsyncState from actor to class.

So this may be an edge case with lazy evaluation of actor owned instance variables.

I believe it has nothing to do with actor or AsyncStream. I encountered the same crash with the following code.

func getX() async -> Int {
  return 10
}

func foo() async {
  lazy var x = await getX()
  print(x)
}

Btw, this code does not compile with Xcode 15.2 neither.

1 Like

Can lazy properties be async? :thinking:

2 Likes

You are right, changing AsyncState from actor to class was simply making the access synchronous rather than async.

Likewise, if getX() is not async, the compiler does not crash.

I think there's a solid reason why it's dangerous, because any read of that property potentially needs to be awaited.

func foo() async {
  lazy var x = await getX()
  print(x)  // <-  this should be an async operation
}

Nowadays there's no language rule supporting this.

2 Likes

edge cases still cut a lot of people! do you have a single-file reproduction of this compiler crash?

Yes, I have submitted a project which demonstrates the crash to the Feedback Assistant. An even simpler example than the one I submitted is:

actor AsyncEntity {
    var i = 0
}

class Crash {
    func here() async {
        let a = AsyncEntity()
        // Any async call in local lazy assignment will crash compiler
        lazy var crash = await a.i  
        print(crash)
    }
}