prism branch T-1756/bugfix-failed-reload-removes-retry-banner commits 3 files 4 touched lines +236 / -8 tests 10/10 pass lint 0 violations

Pre-push review: T-1756 failed reload removes retry banner

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.

At a glance

  • The bug: 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.
  • The fix: acknowledge only after reloadContent actually succeeds.
  • The race the fix opens, and closes: because acknowledgement now happens after an 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.
  • Fixed in review: the mid-reload test's trailing fixed sleep made it capable of passing vacuously. 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.
  • Validation: 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).

Verdict

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.

Review findings

5 raised · 3 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What changed

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.

Why it matters

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.

Key concepts

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.

Architecture

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.

The pattern

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.

Trade-offs

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.

Deep dive

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.

Edge cases traced

  • Overlapping reloads. No button-disable exists during an in-flight reload. Each call captures its own generation synchronously, so there are no lost updates; content/parse ordering is separately covered by the pre-existing parseGeneration guard in parseAndApplyBlocks.
  • Dismiss racing a reload. Safe by monotonicity — both acknowledge paths are clear-only.
  • Nil observer. ?? 0 and the optional-chained call degrade together to no-ops; for a .file source fileObserver is non-nil by construction anyway.
  • Int overflow. Bounded by real file-change notifications, not a loop.
  • URL refresh path. reloadContent(markdownString:) has no FileChangeObserver and correctly wasn't touched.

The test-integrity finding

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.

Important changes — detailed

DocumentLayoutCoordinator: acknowledge on success, not up front

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)

Takeaway. Any 'mark handled' side effect placed before the async work it claims to have handled is a latent bug — it is asserting an outcome it cannot yet know. Move it into the success branch, then ask what can change during the await.
Rationale. Acknowledging up front made the flag a statement about intent ('a reload was requested') while the banner reads it as a statement about outcome ('the change has been applied'). Only the success path can honestly write it.

FileChangeObserver: changeGeneration + acknowledgeChange(upTo:)

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:))

Takeaway. Capture-before / compare-after is the standard defence for a completion handler that lands after a suspension point. The capture must be synchronous with the decision to start the work, or the guard guards nothing.
Rationale. The codebase already uses this idiom three times (DocumentSession.parseGeneration T-718, FootnotePopoverWebPage.renderGeneration, WebDocumentController.processGeneration). Consistency with an established house pattern beat inventing a bespoke mechanism, and no shared abstraction exists to extract — each guards different state.

recordExternalChange(): flag and counter written together

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

Takeaway. When you add a companion field to existing state, funnel every write through one method rather than updating both at each call site. Coherence then holds by construction instead of by review.
Rationale. Extracting it also gave the tests a synchronous way to simulate a change, avoiding the Task hop in presentedItemDidChange() and removing a source of test non-determinism.

Two acknowledge methods, deliberately asymmetric

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

Takeaway. Two callers that both 'clear a flag' can still need different guards. Ask what each caller can honestly vouch for — a user saying 'dismiss' vouches for everything so far; a background task vouches only for the bytes it read.
Rationale. Both paths only ever clear the flag and never set it, so no interleaving of dismiss / reload / re-notify can resurrect it or desync it from the counter. That monotonicity is what makes the asymmetry safe.

reloadDocument returns its Task so tests can await it

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

Takeaway. A test whose failure mode is 'silently passes' is worse than a flaky one — flakes get investigated. When an assertion cannot fail by asserting, check what it actually pins, and prefer exposing a completion handle over tuning a sleep.
Rationale. Polling would patch the symptom; returning the Task removes the timing question entirely. @discardableResult keeps both production call sites (result-discarding closures in the two layouts) unchanged, so the test affordance costs nothing at the call site. The three tests also dropped from seconds to ~7 ms each.

Key decisions

Generation token rather than re-checking file state

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.)
Plain Int with += rather than UInt64 with &+=

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.

(inferred — not stated by the author.)
Retry re-does work rather than resuming

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.)
Expose the Task rather than adding a test-only completion hook

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.

Two separate 'generation' counters left uncoordinated

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.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorDocumentLayoutCoordinatorReloadTests.swift — mid-reload testThe 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.
minorFileChangeObserverTests.swift — presentedItemDidChangeBumpsGenerationUsed 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.
minorDocumentLayoutCoordinatorReloadTests.swift — repeated poll loopsThe 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.
minorFileChangeObserver.swift — bare Int as a race-guard tokenchangeGeneration / 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.
nitFileChangeObserver.swift vs DocumentSession.swift — duplicate vocabularyTwo 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.

Per-file diffs

Click to expand.

prism/Services/FileChangeObserver.swift Modified +37 / -1
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+    } }
prism/Views/DocumentLayoutCoordinator.swift Modified +25 / -2
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)")
prismTests/DocumentLayoutCoordinatorReloadTests.swift Tests +109 / -0
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")+    } }
prismTests/FileChangeObserverTests.swift Tests +70 / -0
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")+    } }

Things to double-check

No button-disable during an in-flight reload

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.

Full test suite not run

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.

The changed reloadDocument signature

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.