A failed external-file reload used to hide the retry banner, stranding the user with no way to retry. The fix moves acknowledgement to the success path and guards it with a change-generation token so a change arriving mid-reload is never swallowed.
reloadDocument called acknowledgeChange() before starting the async reload. The retry banner is gated on fileChangedExternally, so a reload that then failed left the user with no banner and no way to retry short of another external change or reopening the document.reloadContent actually succeeds.await, a fresh change can land mid-reload. A monotonic changeGeneration token, captured synchronously before the reload starts, makes the acknowledgement a no-op if anything newer arrived — so the banner stays up for the change this reload never saw.reloadDocument now returns its Task; the tests await it instead of polling or sleeping. Side benefit: those three tests went from seconds to ~7 ms each.DocumentLayoutCoordinatorReloadTests + FileChangeObserverTests = 10/10 pass on macOS; make lint 0 violations across 528 files. Full suite deliberately skipped (GitHub Actions is billing-blocked and sibling agents are loading the machine).Ready to push
The production change is small, correct, and well-documented. All three review agents cleared the production code: the generation-guard is a correct fourth instance of an idiom the codebase already uses in three other places (parseGeneration, renderGeneration, processGeneration), it is efficiency-neutral, and every caller of acknowledgeChange / fileChangedExternally was traced and verified unaffected.
One major finding was raised and fixed during this review: the mid-reload regression test could pass vacuously. Its final assertion was satisfied the instant the generation was bumped, so the only regression it could actually catch depended on a fixed 100 ms sleep outrunning a background parse — meaning that under CI load the test would pass for the exact regression it exists to catch. reloadDocument now returns its Task so the tests await it deterministically. Targeted classes pass 10/10 and SwiftLint is clean.
9108943 Fix T-1756: Failed external-file reload removes the retry banner 67c225e Guard reload acknowledgement with a change generation token 34cc1df Await the reload task in tests instead of sleeping Prism watches the markdown file you have open. When something else on your machine edits that file — iCloud sync, your editor, a script — Prism shows a small banner offering to reload it.
The banner worked by raising a single flag: the file changed. Pressing Reload lowered the flag immediately, then started reading the file in the background. If that read failed — the file was deleted, or a permissions problem — the flag was already down. The banner vanished, the reload never happened, and there was nothing left to press. Your only way back was to edit the file again or close and reopen the document.
The fix is one sentence long: lower the flag only after the reload actually works.
A retry button that disappears when the thing it retries fails is worse than no button at all — the failure is exactly the moment you need it.
Doing work in the background. Reading and re-parsing a file takes time, so Prism does it in the background and lets the app stay responsive. That is what created the bug: the code lowered the flag up front, before knowing how the background work would turn out.
Why a simple 'lower it at the end' isn't quite enough. If reloading takes a moment, the file can change again during that moment. The reload already read the old version, so lowering the flag at the end would hide a change it never saw. To prevent that, Prism now keeps a counter that ticks up on every change notification. The reload notes the counter when it starts and compares at the end — if the number moved, someone changed the file mid-flight, so the banner stays up.
FileChangeObserver is an @Observable @MainActor NSFilePresenter. presentedItemDidChange() sets fileChangedExternally; both document layouts render ReloadBanner off that flag, with onDismiss wired to acknowledgeChange() and onReload to DocumentLayoutCoordinator.reloadDocument(session:).
The bug was an ordering error. reloadDocument acknowledged first, then kicked off Task { try await session.reloadContent(from: url) }. The acknowledgement was therefore unconditional on an outcome that hadn't happened yet — on the catch path, reloadError got set but the banner was already gone.
Moving the acknowledgement into the do block after the await fixes the reported bug but introduces a narrower one: the acknowledgement now happens on the far side of a suspension point, so a notification can arrive in between and be clobbered. This is a classic stale-completion problem, and the codebase already solves it three separate times — DocumentSession.parseGeneration (T-718), FootnotePopoverWebPage.renderGeneration, WebDocumentController.processGeneration.
So this is a fourth application of a house idiom rather than an invention: a monotonic changeGeneration, captured synchronously before the Task is created, and a acknowledgeChange(upTo:) that no-ops unless the counter still matches.
recordExternalChange() was extracted so the flag and the counter are only ever written together — there is no path that raises one without the other.
The two acknowledge methods encode genuinely different intents. Explicit dismissal (acknowledgeChange()) stays unconditional and correct: the user is saying "I don't care about anything so far", which by definition covers changes the reload never saw. The reload path is the conditional one, because it can only vouch for the bytes it actually read.
Both paths only ever clear the flag and never set it, which is what makes the pair safe against arbitrary interleaving of dismiss / reload / re-notify — there is no ordering in which the flag gets resurrected or desynced from the counter.
The accepted cost: if a change lands mid-reload the banner stays up and the user's retry re-reads and re-parses a file the first reload had already partly handled. Correctness over avoiding a redundant parse in a rare window.
The capture in reloadDocument is load-bearing and its correctness rests on isolation, not luck:
let observedGeneration = session.fileObserver?.changeGeneration ?? 0
return Task { do { try await session.reloadContent(from: url)
session.fileObserver?.acknowledgeChange(upTo: observedGeneration) } … }DocumentLayoutCoordinator is @MainActor, so the read runs to completion with no suspension between it and Task creation. The Task inherits MainActor isolation, so recordExternalChange() — which also runs on the MainActor, hopped there by presentedItemDidChange() — can only interleave at an actual suspension point, i.e. inside reloadContent. That is precisely the window the guard covers. Reading the generation inside the Task instead would reintroduce the bug in a form no direct-invocation test would notice, which is what makes the regression test's integrity matter as much as the fix.
parseGeneration guard in parseAndApplyBlocks.?? 0 and the optional-chained call degrade together to no-ops; for a .file source fileObserver is non-nil by construction anyway.reloadContent(markdownString:) has no FileChangeObserver and correctly wasn't touched.The mid-reload test is the interesting artifact here. Its assertion — fileChangedExternally == true — is satisfied unconditionally the moment recordExternalChange() bumps the generation to 2, because from then on the acknowledgement is guaranteed to be a no-op. The test therefore cannot fail by asserting; it can only fail if a lazy-capture regression lets the acknowledgement match and clear the flag. Whether it observes that depended on a trailing Task.sleep(for: .milliseconds(100)) outrunning two Task.detached parses plus MainActor scheduling.
That is a false-negative generator, not an ordinary flake: under load the test passes for the regression it was written to catch. This file had already learned the lesson once — reloadDocumentUpdatesFootnoteData carries a comment explaining a 500 ms fixed sleep was replaced by polling because "under full-suite load the reload routinely takes longer than that."
Polling would have patched the symptom. Returning the Task removes the timing question entirely: await reloadTask.value means the acknowledgement has provably run before the assertion. @discardableResult keeps the two production call sites — both result-discarding closures in the layouts — untouched.
DocumentLayoutCoordinator.swift
Why it matters. This is the bug fix proper. The acknowledgement was unconditional on an outcome that had not happened yet, so a failed reload silently removed the only affordance for retrying it.
What to look at. DocumentLayoutCoordinator.swift:629-661 (reloadDocument)
FileChangeObserver.swift
Why it matters. Moving the acknowledgement past an await opens a narrower version of the same bug — a change arriving mid-reload would be cleared by a reload that never read it. The generation token closes that window.
What to look at. FileChangeObserver.swift:37-43 (changeGeneration), 103-114 (acknowledgeChange(upTo:))
FileChangeObserver.swift
Why it matters. Keeps the two pieces of state coherent by construction — there is no code path that raises the flag without ticking the counter, which is what makes the guard trustworthy.
What to look at. FileChangeObserver.swift:86-93
FileChangeObserver.swift
Why it matters. It looks like an inconsistency worth flagging until you see the intent split: explicit user dismissal legitimately covers changes the reload never saw; a reload legitimately does not.
What to look at. FileChangeObserver.swift:95-101 vs 103-114
DocumentLayoutCoordinator.swift
Why it matters. Fixes the one major review finding. The mid-reload test's assertion is satisfied the moment the generation is bumped, so the only regression it can catch — a lazy capture inside the Task — was detectable only if a fixed 100 ms sleep outran a background parse. Under load the test would PASS for that regression.
What to look at. DocumentLayoutCoordinator.swift:623-638 (@discardableResult, Task<Void, Never>?); DocumentLayoutCoordinatorReloadTests.swift:174-280
An alternative was to compare file modification dates or content hashes after the reload to decide whether anything newer had landed. Rejected: it re-introduces I/O on the completion path and can still race. A counter incremented by the notification itself is the authoritative signal — it counts notifications observed, which is exactly what the banner represents.
(inferred — not stated by the author.)Both conventions coexist in this repo: DocumentSession.parseGeneration uses UInt64/&+=, FootnotePopoverWebPage.renderGeneration uses Int/+=. This follows the latter. Overflow is not a practical concern — the counter is bumped by real file-change notifications, not in a loop — so the wrapping operator would be ceremony. Noted only because a reviewer familiar with the parseGeneration style might otherwise ask for it.
When a change lands mid-reload the banner stays up, and the user's retry re-reads and re-parses a file the first reload already read. Accepted rather than optimised: auto-retrying or diffing generations to skip the re-parse adds real complexity for a window that only opens when a file changes twice inside one reload's async span.
(inferred — not stated by the author.)Returning Task<Void, Never>? with @discardableResult was chosen over a test-only callback or an @Observable 'reload finished' flag. It adds no production state, leaves both existing call sites untouched, and gives tests a real completion signal instead of an inferred one. Returning nil for a non-file source keeps the existing early-return honest — there is no task to await.
FileChangeObserver.changeGeneration (guards banner acknowledgement) and DocumentSession.parseGeneration (guards stale parse-result application, T-718) sit one call apart in the same reload path, use the same vocabulary, and are independent. They gate different mutations and compose correctly, so unifying them was not attempted — noted here for whoever next debugs a reload race and finds both.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | DocumentLayoutCoordinatorReloadTests.swift — mid-reload test | The trailing fixed Task.sleep(for: .milliseconds(100)) let the test pass vacuously. Its assertion is satisfied the instant the generation is bumped, so the only regression it can catch (generation captured lazily inside the Task) required the sleep to outrun two Task.detached parses plus MainActor scheduling. Under load the test PASSES for the regression it exists to catch — a false negative, not an ordinary flake. This file had already replaced a 500 ms fixed sleep with polling for exactly this reason. | reloadDocument now returns its Task (@discardableResult, nil for non-file sources). All three new tests await reloadTask.value, so the acknowledgement has provably run before they assert. Removes the sleep and all three polling loops; the tests dropped from seconds to ~7 ms each. Both production call sites are unchanged. |
| minor | FileChangeObserverTests.swift — presentedItemDidChangeBumpsGeneration | Used a flat 10 ms Task.sleep after presentedItemDidChange(), which hops to the MainActor via a Task. This is the same fixed-sleep pattern the rest of the PR explicitly avoids, and under full-suite load it is a scheduling-delay flake. | Replaced with a bounded poll on changeGeneration, matching the reasoning used elsewhere in the PR. |
| minor | DocumentLayoutCoordinatorReloadTests.swift — repeated poll loops | The three new tests each duplicated the file's inline polling idiom, bringing it to four near-identical copies; repo convention is a small per-file poll helper (see WebContentTerminationWiringTests.poll(until:)). | Moot after the major fix — awaiting the returned Task removed all three new poll loops. Only the one pre-existing loop remains, so there is nothing left to extract. |
| minor | FileChangeObserver.swift — bare Int as a race-guard token | changeGeneration / acknowledgeChange(upTo:) pass an opaque Int, so an unrelated counter could be passed and silently no-op or silently succeed. An Equatable wrapper struct would make misuse a type error. | Skipped. Single call site, correct today, and the value is only ever round-tripped — never arithmetic. A wrapper type would diverge from the three existing generation guards in this codebase, all of which use bare integers. |
| nit | FileChangeObserver.swift vs DocumentSession.swift — duplicate vocabulary | Two independently-invented 'generation' counters live one call apart in the same reload path. | Skipped as a code change; recorded in the Decisions section instead so the next person debugging a reload race finds the explanation rather than the surprise. |
Click to expand.
diff --git a/prism/Services/FileChangeObserver.swift b/prism/Services/FileChangeObserver.swiftindex f30971e..eaf1b6b 100644--- a/prism/Services/FileChangeObserver.swift+++ b/prism/Services/FileChangeObserver.swift@@ -34,6 +34,14 @@ final class FileChangeObserver: NSObject, NSFilePresenter { /// Indicates whether the file has been modified externally since last acknowledged. var fileChangedExternally = false + /// Monotonically increasing count of external-change notifications.+ ///+ /// Callers that handle a change asynchronously (e.g. a reload that awaits+ /// background parsing) capture this before starting and acknowledge with+ /// `acknowledgeChange(upTo:)` so a change arriving mid-handling is not+ /// silently cleared (T-1756).+ private(set) var changeGeneration = 0+ /// The URL of the file being monitored. private let fileURL: URL @@ -71,15 +79,37 @@ final class FileChangeObserver: NSObject, NSFilePresenter { /// is modified by another process or iCloud sync. nonisolated func presentedItemDidChange() { Task { @MainActor in- fileChangedExternally = true+ recordExternalChange() } } - /// Acknowledges that the change has been handled.+ /// Records an external change: raises the banner flag and bumps the+ /// change generation. Split out of `presentedItemDidChange()` so it runs+ /// synchronously on the MainActor (and so tests can simulate a change+ /// deterministically, without the notification's task hop).+ func recordExternalChange() {+ fileChangedExternally = true+ changeGeneration += 1+ }++ /// Acknowledges that the change has been handled unconditionally. ///- /// Call this method when the user dismisses the reload banner or after- /// the document has been reloaded.+ /// Call this method when the user dismisses the reload banner — an+ /// explicit dismissal covers whatever changes have arrived so far. func acknowledgeChange() { fileChangedExternally = false }++ /// Acknowledges the change only if no newer change has arrived since+ /// `generation` was captured.+ ///+ /// Used by the reload path: the reload reads the file and awaits+ /// background parsing, so a change notification can arrive while it is+ /// in flight. Clearing the flag unconditionally on success would swallow+ /// that change — same symptom as T-1756, narrowed to a race window. With+ /// a stale generation this is a no-op and the banner stays up.+ func acknowledgeChange(upTo generation: Int) {+ guard changeGeneration == generation else { return }+ fileChangedExternally = false+ } }
diff --git a/prism/Views/DocumentLayoutCoordinator.swift b/prism/Views/DocumentLayoutCoordinator.swiftindex 5860dae..77c7a34 100644--- a/prism/Views/DocumentLayoutCoordinator.swift+++ b/prism/Views/DocumentLayoutCoordinator.swift@@ -626,14 +626,33 @@ final class DocumentLayoutCoordinator { /// parse pipeline (footnotes, comment blocks, expansion reset, table /// display mode reset) rather than calling `MarkdownBlockParser.parse` /// directly. See T-724.- func reloadDocument(session: DocumentSession) {- guard case .file(let url) = session.source else { return }+ ///+ /// - Returns: The reload task, so tests can await the success-path+ /// acknowledgement deterministically instead of sleeping (T-1756);+ /// `nil` when the session is not file-backed. Production callers+ /// discard it.+ @discardableResult+ func reloadDocument(session: DocumentSession) -> Task<Void, Never>? {+ guard case .file(let url) = session.source else { return nil } - session.fileObserver?.acknowledgeChange()+ // Capture the change generation before the reload starts. The reload+ // awaits background parsing, so a fresh external-change notification+ // can arrive while it is in flight; the generation-guarded+ // acknowledgement below then no-ops and the banner stays up for the+ // change this reload never saw.+ let observedGeneration = session.fileObserver?.changeGeneration ?? 0 - Task {+ return Task { do { try await session.reloadContent(from: url)+ // Acknowledge only once the reload actually succeeds, and+ // only if no newer change arrived mid-reload. Acknowledging+ // unconditionally up front hid the retry banner (gated on+ // `fileChangedExternally`) even when the reload failed,+ // leaving the user with no way to retry short of waiting for+ // another external-change notification or reopening the+ // document (T-1756).+ session.fileObserver?.acknowledgeChange(upTo: observedGeneration) } catch { reloadError = String(localized: "error.reload", defaultValue: "Reload failed: \(error.localizedDescription)")
diff --git a/prismTests/DocumentLayoutCoordinatorReloadTests.swift b/prismTests/DocumentLayoutCoordinatorReloadTests.swiftindex 8fcd04a..143a973 100644--- a/prismTests/DocumentLayoutCoordinatorReloadTests.swift+++ b/prismTests/DocumentLayoutCoordinatorReloadTests.swift@@ -168,4 +168,113 @@ struct DocumentLayoutCoordinatorReloadTests { #expect(session.content == updatedMarkdown, "Session content must be updated to match the reloaded file") }++ // MARK: - Retry Banner Survives a Failed Reload (T-1756)++ @Test("reloadDocument keeps the retry banner visible when reloadContent throws")+ @MainActor+ func reloadDocumentKeepsBannerOnFailure() async throws {+ let tempURL = FileManager.default.temporaryDirectory+ .appendingPathComponent("reload-failure-\(UUID().uuidString).md")+ let originalMarkdown = "# Document\n\nOriginal content"+ try originalMarkdown.write(to: tempURL, atomically: true, encoding: .utf8)++ let session = DocumentSession(url: tempURL, content: originalMarkdown)+ await session.parseContent()++ // Simulate the file-change notification that shows the retry banner.+ session.fileObserver?.recordExternalChange()++ // Remove the file out from under the session so the async reload's+ // `Data(contentsOf:)` read throws, forcing `reloadDocument` down the+ // catch path. No `defer` cleanup needed: the file is already gone.+ try FileManager.default.removeItem(at: tempURL)++ let coordinator = DocumentLayoutCoordinator()+ // Await the returned reload task: the error-path assignment to+ // `reloadError` has run by the time it completes, so no polling or+ // fixed sleep is needed.+ let reloadTask = try #require(coordinator.reloadDocument(session: session))+ await reloadTask.value++ // Expected: a failed reload must leave `fileChangedExternally` true so+ // the retry banner (gated on that flag) stays available. Actual (bug):+ // `reloadDocument` acknowledged the change unconditionally before the+ // async reload ran, so the banner disappeared even on failure.+ #expect(coordinator.reloadError != nil, "Reload should have failed and set reloadError")+ #expect(session.fileObserver?.fileChangedExternally == true,+ "Retry banner flag must remain set after a failed reload")+ }++ @Test("reloadDocument clears the retry banner on a clean successful reload")+ @MainActor+ func reloadDocumentClearsBannerOnSuccess() async throws {+ let tempURL = FileManager.default.temporaryDirectory+ .appendingPathComponent("reload-success-banner-\(UUID().uuidString).md")+ let originalMarkdown = "# Document\n\nOriginal content"+ try originalMarkdown.write(to: tempURL, atomically: true, encoding: .utf8)+ defer { try? FileManager.default.removeItem(at: tempURL) }++ let session = DocumentSession(url: tempURL, content: originalMarkdown)+ await session.parseContent()++ // Simulate the file-change notification that shows the retry banner,+ // then write the changed content it announced.+ session.fileObserver?.recordExternalChange()+ let updatedMarkdown = "# Updated\n\nNew content"+ try updatedMarkdown.write(to: tempURL, atomically: true, encoding: .utf8)++ let coordinator = DocumentLayoutCoordinator()+ // Await the returned reload task: the success-path acknowledgement+ // has run by the time it completes, so no polling is needed.+ let reloadTask = try #require(coordinator.reloadDocument(session: session))+ await reloadTask.value++ #expect(coordinator.reloadError == nil, "Clean reload must not set reloadError")+ #expect(session.content == updatedMarkdown,+ "Session content must reflect the reloaded file")+ #expect(session.fileObserver?.fileChangedExternally == false,+ "Banner flag must clear once the reload succeeds with no newer change")+ }++ @Test("reloadDocument keeps the banner when a change arrives mid-reload")+ @MainActor+ func reloadDocumentKeepsBannerWhenChangeArrivesMidReload() async throws {+ let tempURL = FileManager.default.temporaryDirectory+ .appendingPathComponent("reload-midflight-change-\(UUID().uuidString).md")+ let originalMarkdown = "# Document\n\nOriginal content"+ try originalMarkdown.write(to: tempURL, atomically: true, encoding: .utf8)+ defer { try? FileManager.default.removeItem(at: tempURL) }++ let session = DocumentSession(url: tempURL, content: originalMarkdown)+ await session.parseContent()++ // First external change: banner comes up, reload will be started.+ session.fileObserver?.recordExternalChange()+ let updatedMarkdown = "# Updated\n\nNew content"+ try updatedMarkdown.write(to: tempURL, atomically: true, encoding: .utf8)++ let coordinator = DocumentLayoutCoordinator()+ // reloadDocument captures the change generation synchronously before+ // its async work starts, and both this test and the reload task run+ // on the MainActor with no suspension in between…+ let reloadTask = try #require(coordinator.reloadDocument(session: session))+ // …so bumping the generation here is deterministically "mid-reload":+ // it lands after the capture and before the success-path+ // acknowledgement, exactly like a second external change arriving+ // while `reloadContent` awaits background parsing.+ session.fileObserver?.recordExternalChange()++ // Await the reload task: its success-path acknowledgement (where the+ // pre-fix code wrongly cleared the flag) has run by the time it+ // completes. Under a lazy-capture regression (generation read inside+ // the task rather than before it) the acknowledgement would match+ // and clear the flag, failing the expectation below deterministically.+ await reloadTask.value++ #expect(session.content == updatedMarkdown,+ "The reload itself must still succeed")+ #expect(session.fileObserver?.fileChangedExternally == true,+ "A change arriving mid-reload must keep the banner up — the reload never saw it")+ } }
diff --git a/prismTests/FileChangeObserverTests.swift b/prismTests/FileChangeObserverTests.swiftindex 58b91c4..d751014 100644--- a/prismTests/FileChangeObserverTests.swift+++ b/prismTests/FileChangeObserverTests.swift@@ -142,4 +142,74 @@ struct FileChangeObserverTests { observer.acknowledgeChange() #expect(observer.fileChangedExternally == false) }++ // MARK: - Generation-Guarded Acknowledgement (T-1756)++ @Test("presentedItemDidChange bumps the change generation")+ @MainActor+ func presentedItemDidChangeBumpsGeneration() async throws {+ let tempURL = FileManager.default.temporaryDirectory+ .appendingPathComponent(UUID().uuidString)+ .appendingPathExtension("md")++ try "# Test".write(to: tempURL, atomically: true, encoding: .utf8)+ defer { try? FileManager.default.removeItem(at: tempURL) }++ let observer = FileChangeObserver(fileURL: tempURL)+ #expect(observer.changeGeneration == 0)++ observer.presentedItemDidChange()++ // `presentedItemDidChange()` hops to the MainActor via a `Task`, so+ // poll for the bump rather than sleeping a fixed duration — under+ // full-suite load a flat 10ms wait is a scheduling-delay flake.+ for _ in 0..<200 where observer.changeGeneration == 0 {+ try await Task.sleep(for: .milliseconds(5))+ }++ #expect(observer.changeGeneration == 1)+ }++ @Test("acknowledgeChange(upTo:) with the captured generation clears the flag")+ @MainActor+ func generationAcknowledgeClearsWhenCurrent() throws {+ let tempURL = FileManager.default.temporaryDirectory+ .appendingPathComponent(UUID().uuidString)+ .appendingPathExtension("md")++ try "# Test".write(to: tempURL, atomically: true, encoding: .utf8)+ defer { try? FileManager.default.removeItem(at: tempURL) }++ let observer = FileChangeObserver(fileURL: tempURL)+ observer.recordExternalChange()+ let captured = observer.changeGeneration++ observer.acknowledgeChange(upTo: captured)++ #expect(observer.fileChangedExternally == false)+ }++ @Test("acknowledgeChange(upTo:) with a stale generation keeps the flag set")+ @MainActor+ func generationAcknowledgeNoOpsWhenStale() throws {+ let tempURL = FileManager.default.temporaryDirectory+ .appendingPathComponent(UUID().uuidString)+ .appendingPathExtension("md")++ try "# Test".write(to: tempURL, atomically: true, encoding: .utf8)+ defer { try? FileManager.default.removeItem(at: tempURL) }++ let observer = FileChangeObserver(fileURL: tempURL)+ observer.recordExternalChange()+ let captured = observer.changeGeneration++ // A second change arrives after the caller captured the generation+ // (e.g. mid-reload). Acknowledging with the stale token must not+ // swallow it.+ observer.recordExternalChange()+ observer.acknowledgeChange(upTo: captured)++ #expect(observer.fileChangedExternally == true,+ "A newer change must survive a stale acknowledgement")+ } }
Nothing stops the user pressing Reload twice. Verified safe — each call captures its own generation synchronously, and content/parse ordering is covered by the pre-existing parseGeneration guard. Worth a conscious look if the reload path later grows state that is not generation-guarded.
Validated with targeted classes only: DocumentLayoutCoordinatorReloadTests + FileChangeObserverTests, 10/10 pass on macOS, plus make lint (0 violations, 528 files). The full suite and the iOS simulator build were deliberately skipped — GitHub Actions is billing-blocked and sibling agents are loading the machine. The blast radius is small (one new method, one changed signature with both call sites verified), but a full run before merge is still the honest check.
reloadDocument now returns Task<Void, Never>? instead of Void. Both production callers (RegularDocumentLayout.swift:296, CompactDocumentLayout.swift:136) are closures that discard the result, and @discardableResult keeps them warning-free — confirmed by a clean build. The unrelated WebDocumentControllerFactory.reloadDocument is a different symbol and untouched.