Unsafe synchronous access to @MainActor isolated data in NSDocument

To read a file in a document-based Mac app, you subclass NSDocument and implement one of its reading methods. NSDocument is @MainActor, and in the simple case all of its methods are called on the main thread.

But it optionally supports concurrent reading, where documents are opened on background threads, so its reading methods are nonisolated. As a result, with Strict Concurrency Checking set to "Complete", this code won't compile:

class Content {
    func replaceText(with string: String) { ... }
}

class Document: NSDocument {
    var content: Content = Content()
    
    // declared as nonisolated on NSDocument
    override func read(from data: Data, ofType typeName: String) throws {
        // 🛑 "Main actor-isolated property 'content' can not be referenced from a non-isolated context"
        content.replaceText(with: String(decoding: data, as: UTF8.self))
    }
}

If you don't opt-in to concurrent reading, the solution is simple:

class Document: NSDocument {
    override func read(from data: Data, ofType typeName: String) throws {
        MainActor.assumeIsolated {
            content.replaceText(with: String(decoding: data, as: UTF8.self))
        }
    }
}

But with concurrent reading, things are more complicated. When you opt-in, each NSDocument is initialized and read on a background thread. Once that's done, ownership is passed to the main thread to show the UI, and the main thread owns the NSDocument going forward. In this case, having MainActor.assumeIsolated in the body of read(from:ofType:) no longer makes sense. To wit, if you keep the above code, you get a runtime panic:

class Document: NSDocument {
    override class func canConcurrentlyReadDocuments(ofType typeName: String) -> Bool {
        true
    }

    override func read(from data: Data, ofType typeName: String) throws {
        MainActor.assumeIsolated { // 💥 Thread 7: Fatal error: Incorrect actor executor assumption; Expected same executor as MainActor.
            content.replaceText(with: String(decoding: data, as: UTF8.self))
        }
    }
}

The truth is that the original version is safe – NSDocument is always isolated to a single thread, just not always the main thread. This is an impedance mismatch between NSDocument and Swift Concurrency.

What's the right way to handle this? I want to tell the compiler "Even though accessing content from outside MainActor looks unsafe, in this case I know it's fine."

3 Likes

After looking at the implementation of MainActor.assumeIsolated, I came up with the following:

extension MainActor {
    @_unavailableFromAsync
    static func unsafeIgnoreActorIsolation<T>(_ operation: @MainActor () throws -> T) rethrows -> T {
        try withoutActuallyEscaping(operation) { fn in
            let rawFn = unsafeBitCast(fn, to: (() throws -> T).self)
            return try rawFn()
        }
    }
}

class Document: NSDocument {
    override func read(from data: Data, ofType typeName: String) throws {
        MainActor.unsafeIgnoreActorIsolation {
            content.replaceText(with: String(decoding: data, as: UTF8.self))
        }
    }
}

It does the same thing as assumeIsolated, except it doesn't assert anything about what executor it's running on. I tested it with concurrent reading disabled and enabled, and all the code runs on the threads that I expect it to run on..

Questions:

  1. Is this correct?
  2. Is there a better way to do this?
  3. Is this something Apple can fix in NSDocument, or does Swift need to change in some way to be able to safely handle this situation? If it's the latter, is it worth starting a thread on Evolution, or is this already in the works?
1 Like

I encountered a similar situation today.

My question is, what's the correct way to check if it's isolated to the MainActor? Is Thread.isMainThread guaranteed to work for it? If not, what's the correct way to avoid crashing?

extension MyViewController: WKUIDelegate {
  nonisolated func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
    guard Thread.isMainThread else { return nil }
    return MainActor.assumeIsolated {
      WKWebView(frame: .zero, configuration: configuration)
    }
  }
}

@nh7a I'm not familiar with the WebKit APIs, but I believe the main actor is always run on the main thread, so I think your code should work fine.