Skip to content

Commit 97af3d7

Browse files
Fix: Make CodeFileDocument's four isolation sites consistent
read(from:ofType:) used a bare MainActor.assumeIsolated, justified by canConcurrentlyReadDocuments(ofType:) being pinned to false. That justification was wrong: the pin constrains AppKit's own reads and says nothing about an in-process caller constructing a document off the main actor. Commit 1985839 records that exact shape trapping here before and taking twenty unit tests down with it. All four nonisolated-override sites now branch on Thread.isMainThread and assume isolation only on the main-thread side. read and presentedItemDidChange block with .sync because both must complete before returning; the LSP notifications and undo registration hop with .async as they already did. docs/architecture-decisions.md is corrected too. It previously described the pin as what made the read path sound, which overstated it.
1 parent 3fa21cd commit 97af3d7

2 files changed

Lines changed: 19 additions & 9 deletions

File tree

‎CodeEditModules/Sources/CodeEditDocument/CodeFileDocument.swift‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -193,20 +193,30 @@ public final class CodeFileDocument: NSDocument, ObservableObject {
193193
Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)")
194194
return
195195
}
196-
// `read(from:ofType:)` overrides a nonisolated `NSDocument` method, but everything below
197-
// touches main-actor state. Reads are main-thread only, which
198-
// `canConcurrentlyReadDocuments(ofType:)` above pins, so stating the isolation is sound.
199196
let text = nsString as String
200-
MainActor.assumeIsolated {
201-
self.sourceEncoding = validEncoding
197+
let installContents: @MainActor () -> Void = { [self] in
198+
sourceEncoding = validEncoding
202199
if let content {
203200
registerContentChangeUndo(fileURL: fileURL, text: text, content: content)
204201
content.mutableString.setString(text)
205202
} else {
206-
self.content = NSTextStorage(string: text)
203+
content = NSTextStorage(string: text)
207204
}
208205
notifyLSPDidOpen()
209206
}
207+
208+
// This overrides a nonisolated `NSDocument` method while everything above touches
209+
// main-actor state. `canConcurrentlyReadDocuments(ofType:)` keeps AppKit's own reads on the
210+
// main thread, but that says nothing about an in-process caller constructing a document off
211+
// it, which has happened before and trapped a bare `assumeIsolated` here. So branch, like
212+
// ``notifyLSPDidOpen()`` and ``presentedItemDidChange()`` do. Unlike those, this blocks:
213+
// `NSDocument` requires the document to be loaded by the time `read` returns, so an async
214+
// hop would return an empty document.
215+
if Thread.isMainThread {
216+
MainActor.assumeIsolated { installContents() }
217+
} else {
218+
DispatchQueue.main.sync { MainActor.assumeIsolated { installContents() } }
219+
}
210220
}
211221

212222
/// The delegate is main-actor isolated, but document reads and closes can happen off the main

‎docs/architecture-decisions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ That surfaced six pre-existing isolation errors, all in code that is byte-identi
140140
Both touch main-actor document state.
141141
Three things now hold that together, and none of them is a static guarantee:
142142

143-
1. `canConcurrentlyReadDocuments(ofType:)` is overridden to return `false`, pinning AppKit's default so reads stay on the main thread. Returning `true` would make the isolation unsound with no compile error.
144-
2. `read(from:ofType:)` uses `MainActor.assumeIsolated`, which relies on (1).
145-
3. `presentedItemDidChange()` branches on `Thread.isMainThread`, because it genuinely arrives on the file-presenter thread in production but on the main thread from tests. An unconditional `DispatchQueue.main.sync` deadlocks the second case.
143+
1. `canConcurrentlyReadDocuments(ofType:)` is overridden to return `false`, pinning AppKit's default so its own reads stay on the main thread. Returning `true` would make that half unsound with no compile error.
144+
2. All four sites that touch main-actor state from a nonisolated override branch on `Thread.isMainThread` and use `MainActor.assumeIsolated` on the main-thread side: `read(from:ofType:)`, `presentedItemDidChange()`, `notifyLSPDidOpen()`/`notifyLSPDidClose(_:)`, and `registerContentChangeUndo`. The pin is not load-bearing on its own, because it says nothing about an in-process caller constructing a document off the main actor, which has happened here before and trapped a bare `assumeIsolated`.
145+
3. Two of the four block rather than hop. `read(from:ofType:)` must, because `NSDocument` requires the document loaded by the time it returns; `presentedItemDidChange()` must, or repeated change notifications pile up. Both therefore carry a `DispatchQueue.main.sync`, whose safety rests on no caller blocking the main thread while triggering an off-main read. Nothing enforces that.
146146

147147
This is accepted as a bridge so the extraction can land, not as the end state.
148148
The real problem is that the type mixes main-actor UI state (`content` is an `NSTextStorage` that SwiftUI observes) with an I/O lifecycle driven from arbitrary threads.

0 commit comments

Comments
 (0)