PR #356 — Fix T-2107: Post-ready WebContent crashes bypass the recovery limit. The crash-recovery retry budget in WebDocumentController reset itself on ready instead of layoutSettled, so a renderer that reliably crashed between the two reloaded forever and never reached the abandonment banner.
layoutSettled — ready alone (bridge up, layout not settled) keeps the failing chain alive, so crash-on-that-cadence documents finally reach the abandonment banner instead of reloading forever.unproductiveRecoveries so a later crash after giving up starts a fresh chain — needed because the new predicate increments rather than reseeding, and pinned by the existing readyAfterAbandonmentClearsTheBanner test.isLayoutSettled (it was provably lockstep — pure duplicated state), the no-document guard was hoisted above the budget charge, three stale/contradictory comments were corrected, and the missing CHANGELOG entry was added.ready-resets-the-budget behaviour as correct; both now describe the layoutSettled milestone, including the manual kill-loop repro recipe.Ready to push
The fix is correct and well-pinned: the budget predicate moved from recoveryInFlight (cleared at ready) to the layoutSettled milestone, the regression test fails against the old code, and the modified productiveRecoveryResetsTheBudget no longer asserts the buggy behaviour. Review found one major redundancy (a stored flag lockstep with isLayoutSettled), one accounting edge (pre-first-load crashes were charged), a missing CHANGELOG entry, and three inaccurate comments — all fixed in a follow-up commit on this branch, verified by lint and a targeted re-run of the 30-test wiring suite. CI is billing-blocked, so make test-quick/make test full runs were not executed this session; the touched test class passes against the fixed code.
753401b Fix T-2107: Post-ready WebContent crashes bypass the recovery limit 20f363e Reword abandon-branch comment to cover the watchdog abandonment path a97d986 Address pre-push review findings for T-2107 Prism shows markdown documents using a web renderer that runs in its own helper process. That process can be shut down by the system (for example under memory pressure), which would leave the page blank — so Prism watches for it and reloads the document automatically. To avoid reloading a hopeless document forever, it keeps a small budget: after a few reload attempts in a row that never come good, it gives up and shows a banner with a Reload button instead.
The bug: Prism decided a reload had "come good" too early. A reloading page announces itself in two steps — first "ready" (the page's plumbing is up) and later "layout settled" (the content has actually finished appearing). The budget was refilled at step one. So a document whose renderer reliably crashed between the two steps looked like a brand-new problem every time, the counter restarted from scratch, and the app reloaded it forever — exactly the endless loop the budget exists to prevent.
A reader with such a document saw it flicker and reload endlessly with no way out except closing the file. Now those crashes count toward the limit, the app gives up after four in a row, and the banner appears offering a manual reload.
One production file (prism/ViewModels/WebDocumentController.swift), one test file (WebContentTerminationWiringTests.swift), and doc updates (CLAUDE.md, docs/agent-notes/webview-rendering-status.md, CHANGELOG.md).
attemptRecovery counts consecutive unproductive recoveries: unproductiveRecoveries = <chain broken?> ? 1 : count + 1, abandoning past maxUnproductiveRecoveries (3). The chain-broken predicate used to be !recoveryInFlight — but markReady() clears recoveryInFlight, and ready fires when the bridge comes up, well before layout settles. The fix introduces hasReachedStabilityMilestone, raised only by markLayoutSettled() and lowered by resetForNavigation() at the start of every fresh attempt, and makes attemptRecovery consult it. recoveryInFlight keeps its other job: classifying a .navigationFailed as a failed recovery reload vs a benign bad link.
Because the predicate now increments on an unbroken chain instead of reseeding to 1, the abandon branch explicitly zeroes the counter — a watchdog-driven abandonment leaves the observation loop armed, so a later crash can reach the accounting with no intervening load and must not instantly re-abandon on the spent budget.
The review folded the stored flag into a computed alias of isLayoutSettled: both were raised/lowered at exactly the same two sites, so the stored copy was duplicated state held in sync by hand ~900 lines apart. The semantic name and the T-2107 rationale comment survive; the drift hazard does not. The alternative — reading isLayoutSettled directly at the call site — would have orphaned the design rationale.
Crash-timing matrix after the fix (all MainActor-synchronous, so no read/clear races): crash during initial load → milestone false → increments from the zero that load set (equivalent to old seed-to-1); crash in the ready-but-unsettled window → increments (the fix; pinned by crashesAfterReadyButBeforeLayoutSettledStayInTheSameChain, whose fourth crash must return false and raise recoveryAbandoned); crash after layoutSettled → milestone read before handleProcessTermination → resetForNavigation clears it, so it correctly seeds 1; recovery-reload failure surfaces as failedProvisionalNavigation and is charged only while recoveryInFlight; post-abandonment crash via the still-armed observation loop starts a fresh chain thanks to the explicit zeroing.
Nil beyond the controller: the flag is private (now a computed alias of isLayoutSettled), @ObservationIgnored semantics are preserved (no new observable state), and the bridge protocol, synchronizer, and view layers are untouched. The docs treat CLAUDE.md's host+bridge paragraph as normative — it previously documented the buggy behaviour as correct, which is how the bug survived review; both it and the manual-repro recipe in the agent note now state that ready alone does not reset the budget.
ready but never settles has no liveness bound: the watchdog is cancelled at markReady, so such a chain parks ready-but-unsettled without abandoning. Pre-existing, acceptable — the page is live and interactive — but worth remembering if a "settled watchdog" is ever proposed.layoutSettled arriving without ready is unreachable through the bridge (JS emits them in order over the same validated channel) and is deliberately untested.load could exhaust the budget and banner a never-shown document) is fixed by hoisting the loadedDocumentURL guard above the accounting; no test pins it, judged acceptable since observation-before-load only occurs in a narrow init window.Fully implemented: the milestone predicate, the abandon-branch budget reset, regression + corrected tests, and all three doc surfaces. Partial: nothing. Missing: no bugfix report in specs/bugfixes/ — consistent with project practice since 2026-07-28 (no fix PR since #332 shipped one), so not charged against this branch.
prism/ViewModels/WebDocumentController.swift
Why it matters. The heart of the fix. The reseed-vs-increment decision now reads hasReachedStabilityMilestone (alias of isLayoutSettled) instead of recoveryInFlight, so crashes in the ready-but-unsettled window accumulate toward the cap instead of restarting the count.
What to look at. WebDocumentController.swift — attemptRecovery, unproductiveRecoveries assignment
prism/ViewModels/WebDocumentController.swift
Why it matters. Behavioural prerequisite of the predicate change. The old predicate reseeded to 1 after abandonment as a side effect; the new incrementing predicate would re-abandon instantly on the leftover count without this reset.
What to look at. WebDocumentController.swift — attemptRecovery abandon guard
prism/ViewModels/WebDocumentController.swift
Why it matters. markReady still clears recoveryInFlight (navigation-failure classification) and the banner, but the comment now states explicitly that the recovery budget is a separate question ready does not answer.
What to look at. WebDocumentController.swift — markReady, markLayoutSettled
prism/ViewModels/WebDocumentController.swift
Why it matters. The original commit stored hasReachedStabilityMilestone as a second Bool raised/lowered at exactly the same two sites as isLayoutSettled — provably lockstep, i.e. duplicated state with a manual-sync hazard ~900 lines apart.
What to look at. WebDocumentController.swift — hasReachedStabilityMilestone (computed), markLayoutSettled, resetForNavigation
prism/ViewModels/WebDocumentController.swift
Why it matters. With the incrementing predicate, terminations before the first load (observation is armed from init) were charged as unproductive recoveries; four of them abandoned and raised the banner over a document that was never shown. An attempt that performs no reload is now not charged.
What to look at. WebDocumentController.swift — attemptRecovery, loadedDocumentURL guard
prismTests/WebRendering/WebContentTerminationWiringTests.swift
Why it matters. crashesAfterReadyButBeforeLayoutSettledStayInTheSameChain drives three ready-only recoveries and asserts the fourth crash abandons — red against the old code. productiveRecoveryResetsTheBudget previously asserted the bug (ready alone restoring the budget); it now settles the page first.
What to look at. WebContentTerminationWiringTests.swift — the two named tests
CLAUDE.md
Why it matters. Both normative docs said a recovery that reaches ready restores the budget — documenting the buggy behaviour as intended, which is how it survived reviews. Both now name layoutSettled, and the agent note's manual kill-loop repro explains that kills separated only by ready stay in the same chain.
What to look at. CLAUDE.md host+bridge paragraph; docs/agent-notes/webview-rendering-status.md item 6
ready only means the bridge came up; layoutSettled means the page actually rendered. A budget protecting against 'the document never comes back' must key on the latter, or crashes in the gap are invisible to it. Stated throughout the fix commit and code comments.
The flag still tells a failed recovery reload apart from a benign bad link (.navigationFailed handling). Only its second, accidental job — gating the budget reset — moved to the milestone. Splitting rather than re-tuning avoids regressing T-1943's classification behaviour.
A watchdog-driven abandonment leaves observation armed (the watchdog discards attemptRecovery's return value), so a later crash can reach the accounting with no intervening load. Commit 20f363e exists purely to make the comment cover this path, after a PR-review nit.
The original commit stored a second Bool with an identical writer set. No rationale for the duplication was stated anywhere — the 16-line doc comment justified the milestone choice, not the copy. The review folded it into a computed alias of isLayoutSettled, preserving the name and rationale while removing the drift hazard.
An attempt that performs no reload is not an unproductive recovery. The old predicate masked this (it reseeded to 1 every time); the new incrementing predicate made four pre-load terminations abandon and banner a never-shown document. The guard now runs before the accounting.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | WebDocumentController state | hasReachedStabilityMilestone was a stored Bool raised/lowered at exactly the same two sites as isLayoutSettled — invariantly equal, duplicated state kept in sync by hand at sites ~900 lines apart. | Folded into a computed alias: private var hasReachedStabilityMilestone: Bool { isLayoutSettled }, with the T-2107 rationale kept on the alias. Behaviour-identical; wiring tests re-run green. |
| major | CHANGELOG.md | No [Unreleased] Fixed entry for T-2107; every one of the last six merged fix PRs added one, and the existing T-1943 entry over-promised ('the app stops retrying') relative to pre-fix reality. | Added a user-voice Fixed entry at the top of the section describing the endless-reload loop and the settled-on-screen success criterion. |
| minor | attemptRecovery accounting | The budget was charged before the loadedDocumentURL guard: with the new incrementing predicate, four terminations before the first load could exhaust the budget and raise the abandonment banner over a document that was never loaded. | Hoisted the guard above the accounting; an attempt that performs no reload is no longer charged. |
| minor | Comment accuracy | markReady's comment claimed a fresh load sets hasReachedStabilityMilestone (load actually clears it and zeroes the counter separately); maxUnproductiveRecoveries' doc still named ready as the budget predicate — the exact rule the ticket demoted. | Both reworded to match the code: only markLayoutSettled raises the milestone; the cap doc now names the stability milestone (layoutSettled). |
| nit | Comment accuracy | attemptRecovery's isReady contrast overclaimed: the chosen predicate also charges a crash during an ordinary load (benign only because load zeroes the counter first), so the recycled 'plus it would also charge…' clause no longer distinguished anything. | Clause dropped; the comment now states only the real distinction (ready vs layoutSettled). |
| nit | Tests | The abandon-branch budget reset is pinned only incidentally by readyAfterAbandonmentClearsTheBanner (named and commented for the banner, not the budget); unproductiveRecoveriesAreCapped's display name still says 'never reach ready'. | Skipped: test files are not modified in a pre-push review unless fixing a bug, and both tests are correct as written. Mutation-checked: removing the reset turns the banner test red. |
| nit | CLAUDE.md (pre-existing) | The unchanged sentence 'giving up … clears the observation task handle' is only true of loop-driven abandonment; watchdog-driven abandonment deliberately leaves observation armed. | Skipped: pre-dates this PR and is not made worse by it; the code comment added in 20f363e documents the asymmetry precisely. |
| nit | specs/bugfixes/ | specs/bugfixes/post-ready-crashes-bypass-recovery-limit/ exists locally but is empty (untracked, will not ship); no bugfix report was written. | Skipped: no fix PR since #332 (2026-07-28) has shipped a report — consistent with current project practice. |
Click to expand.
diff --git a/prism/ViewModels/WebDocumentController.swift b/prism/ViewModels/WebDocumentController.swiftindex a78871e..cc86471 100644--- a/prism/ViewModels/WebDocumentController.swift+++ b/prism/ViewModels/WebDocumentController.swift@@ -358,9 +358,13 @@ final class WebDocumentController { private func markReady() { guard !isReady else { return } isReady = true- // The page came back, so any recovery chain in flight has succeeded: a later- // navigation failure is an ordinary one again, and the next crash starts a- // fresh budget (T-1943).+ // The page came back, so a later navigation failure is an ordinary one again+ // rather than a failed recovery reload (see `applyNavigationOutcome`). The+ // recovery BUDGET is a separate question that `ready` alone does not answer+ // (T-2107): a crash landing after `ready` but before `layoutSettled` is still+ // the same failing chain, not a fresh one — see `hasReachedStabilityMilestone`,+ // which only `markLayoutSettled` raises. (A fresh user-initiated `load`+ // restores the budget separately, by zeroing the counter itself.) recoveryInFlight = false // A page that is rendering is the strongest possible contradiction of "this // document stopped rendering", so the banner goes with the chain. Without this@@ -378,6 +382,9 @@ final class WebDocumentController { private func markLayoutSettled() { guard !isLayoutSettled else { return } isLayoutSettled = true+ // `layoutSettled` — not `ready` — is the meaningful stability milestone that+ // resets the recovery budget (T-2107): it means the page actually rendered,+ // not merely that the bridge came up. See `hasReachedStabilityMilestone`. flushPending() } @@ -981,23 +988,51 @@ final class WebDocumentController { @ObservationIgnored private(set) var lastNavigationOutcome: NavigationObservationOutcome? /// Consecutive attempts within one failing recovery chain — a crash or a failed- /// recovery reload arriving while `recoveryInFlight` is set. Any recovery that- /// reaches `ready`, and any fresh `load`, breaks the chain and restores the budget.+ /// recovery reload landing before the chain has reached a stability milestone+ /// (`hasReachedStabilityMilestone`). Reaching that milestone, and any fresh+ /// `load`, breaks the chain and restores the budget. @ObservationIgnored private var unproductiveRecoveries = 0 /// Whether a recovery reload started by `handleProcessTermination` has not yet /// reached `ready`. Set by that reload, cleared by `markReady` and by any fresh /// `load`. It is what makes a `.navigationFailed` legible: during a recovery it /// means the recovery failed, outside one it means a link did.+ ///+ /// Deliberately NOT what gates the recovery BUDGET reset any more (T-2107) — see+ /// `hasReachedStabilityMilestone`. `ready` fires before layout/media work settles,+ /// so a chain that used the two interchangeably reset to a fresh budget on every+ /// crash that happened to land in the ready-but-not-settled window, and a+ /// WebContent process crashing on that exact cadence reloaded forever without+ /// ever reaching `maxUnproductiveRecoveries`. @ObservationIgnored private var recoveryInFlight = false + /// Whether the current process generation has reached a stability milestone —+ /// `layoutSettled` — since its last recovery attempt started. This, not+ /// `recoveryInFlight`, is what `attemptRecovery` consults to decide whether it is+ /// continuing an existing failing chain or starting a fresh one (T-2107).+ ///+ /// `recoveryInFlight` is cleared by `markReady`, which fires well before a page is+ /// actually stable: `ready` only means the bridge came up, not that layout/media+ /// work has settled. A crash landing after `ready` but before `layoutSettled` used+ /// to find `recoveryInFlight` already false and reset the budget to 1 on every such+ /// attempt, so a WebContent process that reliably crashed in that window reloaded+ /// forever instead of ever reaching `maxUnproductiveRecoveries` and abandoning.+ ///+ /// `isLayoutSettled` already has exactly the lifecycle the budget needs — raised by+ /// `markLayoutSettled`, lowered by `resetForNavigation` at the start of every fresh+ /// attempt (a `load` or a recovery reload), so it always describes the CURRENT+ /// generation, never a torn-down one. This is that flag under the name the budget+ /// logic reasons in, computed rather than stored so the two can never drift apart.+ private var hasReachedStabilityMilestone: Bool { isLayoutSettled }+ /// Set once observation has given up on recovering this document. Observed (not /// `@ObservationIgnored`) so the view can offer a reload. private(set) var recoveryAbandoned = false - /// How many consecutive recoveries may fail to reach `ready` before observation- /// gives up. Without a cap, a WebContent process that cannot be relaunched would- /// have the observer reload in a hot loop forever.+ /// How many consecutive recoveries may fail to reach the stability milestone+ /// (`layoutSettled`, see `hasReachedStabilityMilestone`) before observation gives+ /// up. Without a cap, a WebContent process that cannot be relaunched would have+ /// the observer reload in a hot loop forever. private static let maxUnproductiveRecoveries = 3 /// Starts watching the page's navigation stream for a WebContent termination@@ -1174,19 +1209,40 @@ final class WebDocumentController { /// Reloads the document to recover the renderer, unless the retry budget is spent. /// Returns whether observation continues. private func attemptRecovery(reason: RecoveryReason) -> Bool {+ guard let documentURL = loadedDocumentURL else {+ // Nothing has been loaded yet, so there is no document to restore and no+ // recovery to charge — an attempt that performs no reload must not count+ // toward the budget, or terminations before the first `load` could exhaust+ // it and raise the banner over a document that was never shown.+ // Keep observing: the pending load will arm recovery properly.+ Self.logger.error(+ "Nothing to reload before the first load: \(reason.logDescription, privacy: .public) (category: webcontent)"+ )+ return true+ } // The budget counts CONSECUTIVE attempts within one failing recovery chain.- // `recoveryInFlight` is the honest predicate for that: it is set only by a- // reload this path started and cleared the moment the page reports `ready` (or- // a fresh `load` supersedes the chain). Using `isReady` instead also charged a- // crash that happened during an ordinary load, which is not an unproductive- // recovery at all — the name promised more than the expression delivered.- unproductiveRecoveries = recoveryInFlight ? unproductiveRecoveries + 1 : 1+ // `hasReachedStabilityMilestone` is the honest predicate for that (T-2107): it+ // is raised only once the current generation has actually reached+ // `layoutSettled`, and lowered at the start of every fresh attempt by+ // `resetForNavigation`. Using `recoveryInFlight` — cleared merely by `ready` —+ // reset the budget on every crash that landed after `ready` but before+ // `layoutSettled`, so a process that crashed on that cadence never accumulated+ // toward the cap.+ unproductiveRecoveries = hasReachedStabilityMilestone ? 1 : unproductiveRecoveries + 1 guard unproductiveRecoveries <= Self.maxUnproductiveRecoveries else { Self.logger.error( "WebContent recovery abandoned after \(Self.maxUnproductiveRecoveries, privacy: .public) unproductive attempts (category: webcontent)" ) recoveryInFlight = false cancelRecoveryWatchdog()+ // Giving up closes this chain, so a LATER crash starts counting from+ // scratch rather than immediately re-abandoning on whatever was left over+ // from the spent budget. That crash can reach this path two ways: through a+ // fresh `load` re-arming observation, or — when the WATCHDOG drove this+ // abandonment — through the observation loop itself, which never heard+ // about it (the watchdog discards this return value, see `markReady`) and+ // so stays armed.+ unproductiveRecoveries = 0 // Reloading in a hot loop is not an option, but neither is silence: from // the reader's seat a blank document with nothing but an os_log is exactly // the symptom this ticket fixed. The banner offers the reload that used to@@ -1195,14 +1251,6 @@ final class WebDocumentController { recoveryAbandoned = true return false }- guard let documentURL = loadedDocumentURL else {- // Nothing has been loaded yet, so there is no document to restore.- // Keep observing: the pending load will arm recovery properly.- Self.logger.error(- "Nothing to reload before the first load: \(reason.logDescription, privacy: .public) (category: webcontent)"- )- return true- } // The reason travels with the recovery. Dropping it here made every recovery // log "the WebContent process terminated" — including the two paths that are // NOT a termination — so a blank-document report could not be told apart from@@ -1222,6 +1270,11 @@ final class WebDocumentController { /// the announcement, not to any one page, so only `load` and `abandonLoad` end it. private func resetForNavigation() { isReady = false+ // Lowering `isLayoutSettled` also lowers `hasReachedStabilityMilestone` (its+ // computed alias): every fresh attempt (a `load` or a recovery reload) starts+ // unstable until ITS OWN `layoutSettled` arrives, so `attemptRecovery` can+ // never reset the budget on the settled state of a previously-settled,+ // now-torn-down generation (T-2107). isLayoutSettled = false pendingCommands.removeAll() // The selection the native "Add note" overlay describes belongs to the page
diff --git a/prismTests/WebRendering/WebContentTerminationWiringTests.swift b/prismTests/WebRendering/WebContentTerminationWiringTests.swiftindex 981b784..af4e0d6 100644--- a/prismTests/WebRendering/WebContentTerminationWiringTests.swift+++ b/prismTests/WebRendering/WebContentTerminationWiringTests.swift@@ -316,7 +316,7 @@ struct WebContentTerminationWiringTests { ) } - @Test("A recovery that reaches ready restores the retry budget")+ @Test("A recovery that reaches layoutSettled restores the retry budget") func productiveRecoveryResetsTheBudget() { let controller = makeController() controller.load(@@ -326,9 +326,13 @@ struct WebContentTerminationWiringTests { #expect(controller.applyNavigationOutcome(.webContentTerminated) == true) #expect(controller.applyNavigationOutcome(.webContentTerminated) == true) - // The page comes back this time, so the chain is broken and the budget is- // whole again: the next three recoveries are permitted from scratch.+ // The page comes back AND settles this time, so the chain is broken and the+ // budget is whole again: the next three recoveries are permitted from scratch.+ // `ready` alone is not the stability milestone (T-2107) — see+ // `crashesAfterReadyButBeforeLayoutSettledStayInTheSameChain` below for the+ // half of this that `ready` alone does NOT reset. controller.test_markReady()+ controller.test_markLayoutSettled() #expect(controller.applyNavigationOutcome(.webContentTerminated) == true) #expect(controller.applyNavigationOutcome(.webContentTerminated) == true) #expect(controller.applyNavigationOutcome(.webContentTerminated) == true)@@ -338,6 +342,36 @@ struct WebContentTerminationWiringTests { ) } + @Test("Crashes that keep landing after ready but before layoutSettled stay in the same chain")+ func crashesAfterReadyButBeforeLayoutSettledStayInTheSameChain() {+ // T-2107 regression: `markReady` clears `recoveryInFlight` well before the page+ // is actually stable. A WebContent process that reliably crashes after `ready`+ // but before `layoutSettled` must still accumulate toward the cap and eventually+ // be abandoned — it must NOT get a fresh budget on every such attempt just+ // because `ready` fired, or the observer reloads it forever.+ let controller = makeController()+ controller.load(+ documentURL: URL(string: "prism-doc://document/x?rev=1")!, parseRevision: 1+ )+ #expect(controller.applyNavigationOutcome(.webContentTerminated) == true)+ controller.test_markReady() // Ready, but layoutSettled never arrives before the next crash.+ #expect(controller.applyNavigationOutcome(.webContentTerminated) == true)+ controller.test_markReady()+ #expect(controller.applyNavigationOutcome(.webContentTerminated) == true)+ controller.test_markReady()+ #expect(+ controller.applyNavigationOutcome(.webContentTerminated) == false,+ """+ Four consecutive crashes that never reach `layoutSettled` must exhaust the \+ budget and abandon, even though `ready` fired before every one of them. \+ Before T-2107 this stayed `true` forever: `markReady` cleared \+ `recoveryInFlight`, which `attemptRecovery` read as "the chain is broken", \+ resetting the count to 1 on every attempt.+ """+ )+ #expect(controller.recoveryAbandoned)+ }+ // MARK: - 4. A recovery reload that fails as an ordinary navigation @Test("A recovery reload that fails as an ordinary navigation is retried")
diff --git a/CLAUDE.md b/CLAUDE.mdindex 2f08372..a14f529 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -50,7 +50,7 @@ The document is rendered by WebKit-for-SwiftUI (`WebView`/`WebPage`). The SwiftU 1. **Parse**: `swift-markdown` → AST → `MarkdownBlock` enum variants (`MarkdownBlockParser`), unchanged from before. T-1558 made the model lossless for nested blockquotes, ordered-list `start`, and rich blocks inside list items (see `specs/web-markdown-fidelity/`). 2. **Emit**: `BlockHTMLEmitter` (`prism/Services/WebRendering/`) is a pure, deterministic function of `[MarkdownBlock]` + `FootnoteData` + `RenderSettings`. It emits one `<section>` per block carrying the content-hash block identity and an occurrence-qualified DOM id (`b-{hash}-{sourceIndex}`, allocated via the shared `BlockDOMID`), escapes by default, and is total (a block that fails to emit falls back to escaped-source `<pre>`, never dropped). `InlineHTMLRenderer` wraps mappable text runs in `<span data-prism-run>` and records a `DocumentSourceMap` (UTF-16 offsets, shipped as an inert `<div hidden>` data island) for selection-anchored notes. `emit` (and the model/service value types it reads) is `nonisolated`, so it runs off the MainActor: `WebDocumentControllerFactory.precomputeDocumentHTML` emits once per `parseRevision` on a `Task.detached` and caches the HTML on `DocumentSession`; the scheme handler serves that cache (synchronous on-main emit only on a miss). Its per-block/inline `HTMLSanitizer` (SwiftSoup) passes are serialized behind a shared `Mutex` because SwiftSoup keeps unsynchronized static pools (T-1681, `specs/offmain-html-emit/`). 3. **Serve**: `PrismDocSchemeHandler` (`prism-doc://` `URLSchemeHandler`) is the single audited I/O path — it serves the document HTML, `document.css` (the only asset fetched through the scheme), and mediates every image subresource through `/img/?src=` (rewritten absolute/relative URLs routed via `ImagePathResolver`/`ImageLoader`/`SVGSourceLoader`). It serves the verbatim CSP (`script-src 'none'`, `connect-src 'none'`, …) as a response header. The document is loaded via the scheme, never `loadHTMLString`.-4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot. That termination is observed by `startNavigationObservation()`, armed from the controller's `init` — it was missing entirely until T-1943, so the whole recovery path was dead code in production. `WebPage` offers no delegate callback and no Observable property for a crash: it surfaces as `WebPage.NavigationError.webContentProcessTerminated` **thrown** by `page.navigations`, which ENDS the sequence — and which also throws for ordinary navigation failures — so the observer classifies the error (`drainNavigationStream`) and re-subscribes (`applyNavigationOutcome`), or the first failed navigation would silently disarm crash recovery for the rest of the session. A recovery reload that fails as an ORDINARY navigation is the same failure wearing a different error, so `recoveryInFlight` makes a `.navigationFailed` legible: during a recovery it is charged and retried, outside one it is a benign bad link. Recovery gives up after `maxUnproductiveRecoveries` consecutive attempts that never reach `ready`, rather than reloading in a hot loop — and giving up is neither silent nor permanent: it clears the observation task handle, raises `recoveryAbandoned` (the banner in `DocumentScrollContent`), and any fresh `load` restores the budget and re-arms observation. Because a direct-invocation test cannot see missing wiring (that is exactly how T-1943 survived the cutover and every review), the production subscription is pinned by a live test over a real `WebPage`: `WebContentTerminationWiringTests.controllerObservesItsOwnPageNavigationStream`. `WebDocumentStateSynchronizer` (T-1719) is the single production owner that pushes native truth (sections, details open-state, table modes, notes, typography, comment visibility) to the controller and routes navigation targets (TOC/fragment via `session.pendingAnchorScroll`, notes via `coordinator.noteNavigationTarget`, search current match) through `controller.scrollTo` with `BlockDOMID.navigationDOMID` id translation — Observation-framework driven, so it works with no view mounted; `DocumentScrollContent` mounts the whole assembly via `WebDocumentStateSynchronizer.makeAssembly`. Two inputs are view-fed, because both are view-world environment values: the palette, pushed via `applyTheme(themeKey:contrast:)` — the colorScheme-resolved theme key plus `colorSchemeContrast`, grouped as a `WebPaletteFeed` so a single `.onChange` pushes them together and they can never be applied out of step (T-1829) — and `dynamicTypeSize`, fed in via `start(dynamicTypeSize:)` / `applyDynamicTypeSize(_:)`, which gets NO push of its own: the synchronizer folds it into the typography domain, because `applyTypography` carries one variables dict that wholly replaces the snapshot's typography, so a second pusher would drop the settings-derived half from the recovery replay (T-1828, font-settings Decision 18).+4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot. That termination is observed by `startNavigationObservation()`, armed from the controller's `init` — it was missing entirely until T-1943, so the whole recovery path was dead code in production. `WebPage` offers no delegate callback and no Observable property for a crash: it surfaces as `WebPage.NavigationError.webContentProcessTerminated` **thrown** by `page.navigations`, which ENDS the sequence — and which also throws for ordinary navigation failures — so the observer classifies the error (`drainNavigationStream`) and re-subscribes (`applyNavigationOutcome`), or the first failed navigation would silently disarm crash recovery for the rest of the session. A recovery reload that fails as an ORDINARY navigation is the same failure wearing a different error, so `recoveryInFlight` makes a `.navigationFailed` legible: during a recovery it is charged and retried, outside one it is a benign bad link. Recovery gives up after `maxUnproductiveRecoveries` consecutive attempts that never reach the stability milestone that resets the budget — `layoutSettled`, not merely `ready` — rather than reloading in a hot loop; `ready` alone used to reset it, so a crash landing after `ready` but before `layoutSettled` restarted the chain at attempt one every time and could reload forever without ever hitting the cap (T-2107). Giving up is neither silent nor permanent: it clears the observation task handle, raises `recoveryAbandoned` (the banner in `DocumentScrollContent`), and any fresh `load` restores the budget and re-arms observation. Because a direct-invocation test cannot see missing wiring (that is exactly how T-1943 survived the cutover and every review), the production subscription is pinned by a live test over a real `WebPage`: `WebContentTerminationWiringTests.controllerObservesItsOwnPageNavigationStream`. `WebDocumentStateSynchronizer` (T-1719) is the single production owner that pushes native truth (sections, details open-state, table modes, notes, typography, comment visibility) to the controller and routes navigation targets (TOC/fragment via `session.pendingAnchorScroll`, notes via `coordinator.noteNavigationTarget`, search current match) through `controller.scrollTo` with `BlockDOMID.navigationDOMID` id translation — Observation-framework driven, so it works with no view mounted; `DocumentScrollContent` mounts the whole assembly via `WebDocumentStateSynchronizer.makeAssembly`. Two inputs are view-fed, because both are view-world environment values: the palette, pushed via `applyTheme(themeKey:contrast:)` — the colorScheme-resolved theme key plus `colorSchemeContrast`, grouped as a `WebPaletteFeed` so a single `.onChange` pushes them together and they can never be applied out of step (T-1829) — and `dynamicTypeSize`, fed in via `start(dynamicTypeSize:)` / `applyDynamicTypeSize(_:)`, which gets NO push of its own: the synchronizer folds it into the typography domain, because `applyTypography` carries one variables dict that wholly replaces the snapshot's typography, so a second pusher would drop the settings-derived half from the recovery replay (T-1828, font-settings Decision 18). 5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back. Every interactive piece of that chrome is a NATIVE `<button>` or `<a href>` (T-1725) — never a `div`/`span` with `role="button"` — so the user agent supplies focusability, tab order, and Enter/Space activation, and there is no synthetic key handling to keep in sync. Those elements suppress their UA appearance, so `document.css` must reset it; the indicator dot's `font-size: 1em` is load-bearing rather than cosmetic, since the dot's whole gutter geometry is expressed in em. Accessible names are native-owned because the JS cannot reach the string catalog: the indicator's name rides the `setNoteIndicators` payload (`label`, pluralised via `NoteRenderStrings.noteIndicator`), the bubble's action label is baked in by `NoteHTMLBuilder` as visually-hidden text (an `aria-label` there would *replace* the note's own text in the accessible name), and the add-note "+" reads `<main data-prism-add-note-label>`. Both push handlers rebuild all chrome, so each control carries a `data-prism-focus-key` and `prism-notes.js` captures/restores focus around the rebuild. 6. **Search**: counts and navigation order stay in `SearchService`/`SearchCoordinator`. `SearchStateFeeder` translates that into a per-block `setSearchState` payload; `prism-search.js` re-finds the query in each block's rendered text and registers ranges on two named **CSS Custom Highlights** (`prism-search`, `prism-search-current`), windowed to the viewport. The web view's built-in find navigator stays disabled so Cmd+F routes to Prism's search. 7. **Security**: `HTMLSanitizer` (over SwiftSoup) reduces raw HTML embedded in markdown to an allowlist subset on load (Req 1.8/8.1); its `plainText` feeds searchable text. Combined with `allowsContentJavaScript = false` and the served CSP, active-content vectors are blocked by construction.
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex 6e0f014..fd8b2a8 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -201,18 +201,24 @@ termination branch is edited. **Read the safety note before running it.** filter with `log stream --predicate 'subsystem CONTAINS "prism"' --info` or Console.app. The same line names `the recovery reload failed` / `the recovery reload never reported ready` when the reload itself is what went wrong, so the log says which path ran.-6. Exercising the give-up path takes four crashes **without an intervening `ready`** —- not merely four kills. The budget counts one failing recovery CHAIN: any recovery that- reports ready breaks the chain and restores the full allowance, so kills spaced far- enough apart for the document to come back each restart the count at 1 and never- abandon (that is the correct behaviour, not a failure to reproduce). To reach- abandonment, kill the relaunched WebContent process again inside the reload window,- before the recovered document renders — script the PID-verify + `kill` (see the safety- note: verify each PID, never pattern-kill) and repeat it immediately, or use a document- large enough that the reload window is comfortably wide. After the fourth unproductive- attempt the log carries `WebContent recovery abandoned…` and the "This document stopped- rendering." banner appears with a Reload button. Tapping it must both restore the- document and re-arm recovery (kill once more — it should recover again).+6. Exercising the give-up path takes four crashes **without an intervening+ `layoutSettled`** — not merely four kills, and NOT merely four kills separated by+ `ready` (T-2107). The budget counts one failing recovery CHAIN: any recovery that+ reaches `layoutSettled` breaks the chain and restores the full allowance, so kills+ spaced far enough apart for the document to fully settle each restart the count at 1+ and never abandon (that is the correct behaviour, not a failure to reproduce).+ `ready` alone does NOT reset the budget — it fires before layout/media work settles,+ so a kill that lands after `ready` but before `layoutSettled` stays in the same+ chain. (Before T-2107 the budget reset on `ready` alone, so a WebContent process that+ reliably crashed in that window never reached abandonment at all — this was the bug,+ not a property of the harness.) To reach abandonment, kill the relaunched WebContent+ process again inside the reload window, before the recovered document's layout+ settles — script the PID-verify + `kill` (see the safety note: verify each PID, never+ pattern-kill) and repeat it immediately, or use a document large enough that the+ reload window is comfortably wide. After the fourth unproductive attempt the log+ carries `WebContent recovery abandoned…` and the "This document stopped rendering."+ banner appears with a Reload button. Tapping it must both restore the document and+ re-arm recovery (kill once more — it should recover again). 7. The stall path (a reload that never reports ready) has no kill that raises it by hand; it is covered by the watchdog tests in `WebContentTerminationWiringTests`. What is worth checking by hand is the negative: open a LARGE document, kill once, and confirm
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5224d5a..f328244 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before. - Choosing where to go while a document reloads now takes you there (T-1975). If a file changed on disk — or a URL document was refreshed — while you had it open, and during the moment the app spends preparing the new version you picked a table-of-contents entry, tapped a note, followed a link to a heading, or stepped to a search match, the reloaded document appeared at your saved reading position instead. What you chose was handed to the copy still on screen, which was about to be replaced, so nothing was left to say where you had asked to go and restoring your place won — and on a large document, where preparing the new version takes longest, that window is at its widest. The document on screen is now treated as superseded from the moment a reload starts rather than from the moment the new version is ready, so anything you choose in between is held for the version that is coming and takes precedence over your saved place, exactly as it already did when you chose a moment later. This holds when a file changes twice in quick succession, so a second reload beginning before the first has finished preparing still takes you where you asked rather than back to your saved place. Reloads you did not navigate during still return you to where you were reading, and once the reloaded document has taken you where you asked, the next reload restores your place normally. Scrolling while a reload prepares still counts too, however you do it — dragging, a trackpad or wheel, **Page Up** and **Page Down**, or **Scroll to Top** and **Scroll to Bottom** — because the document stays in front of you and stays scrollable the whole time: the place you scroll to is the place you are returned to. - Changing the reading font or text size no longer moves you somewhere else in the document (T-1965). Both settings already applied without reloading, but they reflow the whole document and nothing put you back afterwards: raising **Larger Text** to an accessibility size makes every block roughly three times as tall, so the text you were reading slid off the bottom of the screen and left you looking at something you had already been through. The app then recorded that new spot as where you were reading, so closing and reopening the document returned you to it as well. Your place is now kept across the change — including how far into a paragraph you were, so the same words stay in front of you rather than merely the same paragraph starting at the top — and a place you were never reading can no longer be saved while the document settles. Jumping somewhere while the change is settling wins: a table-of-contents entry, a link, a note, or a search match all take you where you asked, and the re-anchoring steps aside. Collapsing the section you were reading during the change leaves you at its heading rather than at content that is no longer shown. - A document that goes blank because its rendering process stopped now restores itself (T-1943). The app has always been able to recover from this — it reloads the document and puts back your theme, your reading position, your note markers, and any active search highlights — but nothing was ever watching for the rendering process to stop, so the recovery never actually ran. A large or image-heavy document whose renderer was shut down under memory pressure therefore showed an empty page, with no error and no way back except closing the file and opening it again. The app now watches for it and recovers on the spot. An ordinary failure to load — a link that goes nowhere, an image that cannot be fetched — is told apart from a stopped renderer, so it neither causes a needless reload nor stops the app watching for a real one afterwards. The recovery also covers its own failure: if the reload it starts cannot itself load the document, that counts as the recovery failing and is tried again, instead of leaving the page blank with nothing running. A reload that neither succeeds nor fails — one that simply never finishes — is covered too: it is given a generous time limit, well beyond what even a large document takes to appear, and is then treated as a failed recovery and tried again rather than leaving the page blank indefinitely. If reloading repeatedly fails to bring the document back, the app stops retrying rather than reloading over and over — and says so, with a banner offering to reload. Taking that reload also restores the document's ability to recover on its own again, so giving up is never permanent while the file stays open.
Per instruction, validation was targeted: make lint plus -only-testing:prismTests/WebContentTerminationWiringTests on macOS (green, including against the review-fix commit). CI is billing-blocked. If you want belt-and-braces before pushing: make test-quick, make build-ios, make build-macos.
The recovery watchdog is cancelled at markReady, so a recovery that reaches ready but never settles parks without abandoning. Pre-existing and arguably correct (the page is live and interactive), but it is the one place where the new milestone and the watchdog's gate (!isReady) intentionally differ.
The agent note's updated item 6 describes reaching abandonment by hand (four kills without an intervening layoutSettled). Worth one manual pass on a large document if you want end-to-end confidence beyond the wiring tests — the tests drive the seams, not a real WebContent kill cadence.