prism branch T-2133/bugfix-ipad-renderer-closed-scene commits 3 files 6 touched (3 prod, 2 test, 1 changelog) lines +487 / -2 prod lines +115 / -2 test lines +371 build macOS clean, 0 warnings build iOS 1 warning (new) lint 0 violations / 556 files verify-test-isolation OK

Pre-push review: T-2133 iPad renderers stay attached to a closed scene

Three commits fixing T-2133 — offscreen render WebViews that keep pointing at an iPad scene's window after that scene has closed, so JavaScript execution silently fails or times out in whichever scene is still open. Reviewed against origin/main (PR #387, repo ArjenSchwarz/prism) as an independent audit: no production code was changed by this review.

At a glance

  • Blocker: MermaidRenderer.swift:209try on a call that cannot throw. iOS build emits warning: no calls to throwing functions occur within 'try' expression. Confirmed in a real xcodebuild build -destination 'platform=iOS Simulator'; macOS build is clean, which is why it was missed.
  • Major: UIWindowHostState is untested. MockAttachedHostState supplies both booleans directly, so the derivation from a real UIWindow is never exercised. make test does run the whole prismTests target on the iOS Simulator with no filter, and MockWindow/WebViewPoolIOSWindowSelectionTests already prove the pattern — so the stated “not buildable on macOS” justification does not cover this gap.
  • Verified sound: the iOS re-attach sources windows from UIApplication.shared.connectedScenes, which by definition excludes the disconnected scene, and prefers .foregroundActive. The validator and the selector share the same predicate, so they can never disagree — no re-attach thrash.
  • Verified sound: no macOS regression. renderInternal's macOS branch is byte-identical to before; the whole WebViewPool addition sits inside #if os(iOS); macOS build has zero warnings.
  • Verified sound: no semaphore or slot leak when re-attach throws. The throw lands inside withWebView's guarded do, before activeSlots.insert(index), and the catch releases the permit.
  • Verified sound: the structural pin is not vacuous. I replicated its functionBody/stripLineComment/braceDelta algorithm and ran four mutations: deleting the call from either site is caught; reordering is not (documented); a """ literal would silently void the isolation guarantee (harmless today).
  • No conflict with T-2134. That ticket lives in captureSnapshot, which this diff does not touch, and the pin scans only getOrCreateWebView's body. Merge order is free — but this fix does not subsume T-2134: a scene closing mid-render still leaves an unbounded snapshot.

Verdict

Needs fixes

The fix itself is correct. Both real attachment sites are covered, the iOS re-attach cannot pick the closing scene, macOS is provably untouched, and the semaphore/slot accounting survives a throwing re-attach. The structural wiring pin is not vacuous — I verified empirically that deleting the call from either call site fails it.

Two things must be fixed before push. First, a new compiler warning in the changed code (MermaidRenderer.swift:209, iOS only) breaks the project's own zero-warning pre-push gate — and reintroduces a warning on the branch whose tip commit is literally “Clear every APP-TARGET compiler warning”. Second, UIWindowHostState — the sole production conformance, and the only code on this branch that can actually get the scene check wrong — has zero test coverage: rewriting sceneIsConnected to { window != nil }, which deletes the entire T-2133 check, leaves every test on this branch green. Both are closable with what is already in the repo.

Review findings

12 raised · 0 fixed · 12 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism draws two kinds of picture using an invisible web browser hidden off the edge of the screen: Mermaid diagrams and SVG images. A hidden browser like that only works if it is planted inside a real window — otherwise the JavaScript inside it never runs.

On iPad you can have two Prism windows open side by side. Prism planted its hidden browser into whichever window existed at the time, once, and then never looked again. If you closed that window and kept working in the other one, the hidden browser was still planted in the window that no longer existed. Diagrams stopped appearing — not with an error, just a ten-second wait and then nothing.

The fix is to check before each use: is the window I am planted in still a real, open window? If not, pull out and re-plant into a window that is open.

Why it matters

The failure was silent and looked like the app was broken. Nothing logged an error; the diagram just never showed up. And it persisted for the rest of the session, because nothing ever re-checked.

Key concepts

  • Scene — Apple's word for one window of an app. An iPad app can have several at once, and any of them can be closed at any time.
  • Stale reference — still holding on to something after it has stopped being usable. The classic version is a pointer to freed memory; this one is subtler, because the window object is still there, it has just stopped counting.
  • Revalidate before use — instead of trusting a fact you established earlier, re-establish it each time you rely on it. Cheap here, because the check is two property reads.

Architecture

Two independent sites hold a long-lived WKWebView attached to a UIWindow by hand:

  • MermaidRenderer attaches once in setupWebView() and caches the window in a weak var attachedWindow. It is itself cached — DiagramCache.getOrCreateRenderer() builds one lazily and keeps it — so it outlives any individual scene.
  • WebViewPool attaches each pooled WebView once, on creation, in attachToWindowHierarchy. SVGRenderer is its only consumer.

Neither re-checked. Before this change only #if os(macOS) had any attachment check in renderInternal at all — and macOS could not have the bug, because it hosts renderers in a dedicated off-screen NSWindow retained for the process lifetime.

The shape of the fix

Rather than inline the check twice, the branch introduces a two-piece seam in RenderingUtilities.swift:

  • protocol AttachedHostState — two booleans, windowIsPresent and sceneIsConnected, deliberately free of UIKit types.
  • enum WindowAttachmentValidatorisStillLive(_:) (the pure conjunction) and reattachIfNeeded(_:detach:reattach:) (guard, then detach, then re-attach, in that order, rethrows).

The single production conformance, UIWindowHostState, lives in WebViewPool.swift under #if os(iOS). Each call site becomes one guarded call.

Patterns

The seam exists for a specific reason: make test-quick runs on macOS, so an #if os(iOS) body never executes there. Expressing the decision against a platform-free protocol makes it runnable in the fast loop.

The branch also carries a structural test — WindowAttachmentWiringTests reads the production source, isolates each function body by brace counting, and asserts the call is still there. That is this repo's established answer (see ImageMaterializationChokepointTests, URLChokepointAdoptionTests) to the T-1943 lesson: a direct-invocation test cannot see missing wiring, because it never asks production code to reach the seam on its own.

Trade-offs

The abstraction buys macOS-runnable coverage of the half that cannot fail (a && b) and leaves the half that can — the UIKit derivation in UIWindowHostState — with none. That is the branch's main residual, and it is closable: make test runs the whole target on the iOS Simulator.

Correctness of the re-attach target

The load-bearing question is whether the re-attach can land back on the scene that is going away. It cannot, for a structural reason rather than a defensive one: both MermaidRenderer.attachToWindowLegacy and WebViewPool.attachToWindowHierarchy derive candidates from UIApplication.shared.connectedScenes, and a disconnected scene is by definition absent from that set. selectIOSWindow then orders by activation state — .foregroundActive, then .foregroundInactive, then .background, skipping .unattached — so the surviving foreground scene wins whenever one exists.

More importantly, the validator and the selector share the same predicate. sceneIsConnected tests connectedScenes.contains(window.windowScene); selectIOSWindow returns a window belonging to a scene drawn from that same set. So a freshly selected window can never be reported stale by the very next check — there is no oscillation, and no possibility of a re-attach loop. The residual is narrow: during sceneWillDisconnect the scene is still in connectedScenes, so if no scene is .foregroundActive the closing one remains eligible. The next render revalidates and re-picks, and this hazard is pre-existing in selectIOSWindow (T-745), untouched here.

Failure atomicity and slot accounting

getOrCreateWebView is @MainActor and synchronous, and every closure it passes is non-async — there is no suspension point anywhere in the re-attach sequence, so concurrent withWebView calls cannot interleave through it. When attachToWindowHierarchy(existing) throws .noWindowHierarchy, the throw surfaces inside withWebView's guarded do, before activeSlots.insert(index), and the catch awaits semaphore.release(). No permit leak, no slot leak.

What it does leave is a detached WebView in pool[index]detach() already ran. That contradicts the doc comment's invariant (“stored in the pool only after successful window attachment”), but is behaviourally benign and self-healing: the next checkout observes existing.window == nil, judges it stale, and retries the attach. pool[index] has no other reader (clear() and the process-termination handler only nil it), so nothing can consume the detached instance. Keeping it is arguably better than nilling, which would discard a warmed WebContent process.

Where the coverage actually stops

For the production conformance, windowIsPresent is implied by sceneIsConnectedguard let scene = window?.windowScene else { return false } already fails on a nil window. So isStillLive collapses to sceneIsConnected, and the four truth-table tests pin a && b, not a decision. The two-property split does usefully document that liveness has two failure modes; the tests just cannot reach either derivation.

Concretely: rewrite sceneIsConnected as { window != nil } — deleting the scene check entirely, i.e. reintroducing T-2133 — and every test on this branch stays green. MockAttachedHostState supplies the booleans; WindowAttachmentWiringTests only greps for reattachIfNeeded(. This is the T-1943 lesson the branch's own doc comments cite, reproduced one level down.

Structural pin: verified non-vacuous, with two documented seams

I replicated the functionBody / stripLineComment / braceDelta algorithm and ran mutations against committed source. Both function bodies isolate exactly (22 and 80 lines, terminating at the right brace). Deleting the call from either site is caught. Two things are not: moving the call to a position where it does nothing (explicitly documented under “What it cannot see”), and a """ multi-line literal appearing inside a scanned function — three quotes toggle the state machine an odd number of times, desyncing brace counting and silently over-running the body to end-of-file. Harmless today because reattachIfNeeded( appears nowhere else in either file, and ProductionSourceScan.unclosedParens shares the limitation, so “mirrors” is a fair claim — it is the phrase “ignores anything inside a string literal” that is stronger than the code delivers.

Interaction with T-2134

WebViewPool.captureSnapshot awaits takeSnapshot with no timeout race, unlike awaitNavigation. That is T-2134's territory and this diff does not touch it. The two are orthogonal in every direction that matters: no shared function, no shared state, and the wiring pin scans only getOrCreateWebView's body, so a T-2134 edit to captureSnapshot cannot break it. Merge order is free.

The relationship is worth stating precisely, because it is easy to over-read: T-2133 removes a leading cause of a hung or blank snapshot (checking out a WebView already attached to a dead window). It does not remove the need for T-2134's backstop, because both call sites revalidate on entry only. A scene that closes 200 ms into a render still runs to completion of the timeout budget — 10 s via RenderingUtilities.withTimeout for Mermaid, unbounded for the pool's snapshot. The CHANGELOG's “before every render” is accurate but reads as a stronger guarantee than “at the start of every render”.

Coverage of attachment sites

Every manual addSubview of a WebView on iOS is at WebViewPool.swift:428 and MermaidRenderer.swift:168 — both covered. DiagramZoomWebView and SVGWebView are UIViewRepresentables (SwiftUI-hosted, no manual attachment); WebDocumentController/FootnotePopoverWebPage use WebPage. InlineNotesShareHelper selects a window fresh at each share and caches nothing. SystemColorSchemeObserver caches registeredWindow but already re-registers on the active window across multi-window teardown. Nothing was missed.

Important changes — detailed

RenderingUtilities: platform-free AttachedHostState + WindowAttachmentValidator

RenderingUtilities.swift

Why it matters. This is the whole design decision of the branch. Expressing the liveness check against two plain booleans rather than UIKit types is what makes it runnable in the macOS test-quick loop — and is also what puts the only code that can genuinely be wrong (the UIKit derivation) outside the reach of those tests.

What to look at. RenderingUtilities.swift:5-65 — protocol AttachedHostState, enum WindowAttachmentValidator { isStillLive, reattachIfNeeded }

Takeaway. When a decision must be tested in a loop that does not compile the platform it ships on, split the decision from its derivation — but then say plainly which half the tests reach. Half a seam tested is a coverage claim that reads stronger than it is.
Rationale. Round-2 review feedback: the first shape inlined `if !isStillLive { detach(); reattach() }` at each site, so the decide-then-act sequencing was only checkable by scanning source text. Hoisting it into `reattachIfNeeded` made the sequencing (and the throw-after-detach ordering) directly live-testable with spy closures, leaving the structural pin a narrower job. (inferred — not stated by the author)

MermaidRenderer.renderInternal: the iOS branch that did not exist

MermaidRenderer.swift

Why it matters. Before this change `renderInternal` had NO attachment check on iOS — the guard was `#if os(macOS)` only. The renderer is cached by DiagramCache.getOrCreateRenderer, so it outlives any scene. This is the primary bug site, and it is also where the new compiler warning lives.

What to look at. MermaidRenderer.swift:199-224 — new #if os(iOS) branch; the redundant `try` is at :209

Takeaway. A `rethrows` helper is only as throwing as the closures you hand it. The two call sites here differ precisely on that: WebViewPool's `reattach` throws so its `try` is required, Mermaid's does not so its `try` is dead. The asymmetry is what confirms the diagnosis — and why one platform's build is clean while the other's is not.
Rationale. The follow-on `if attachedWindow == nil { throw }` exists because `attachToWindowLegacy()` returns Void and silently no-ops when no window is found, so the caller has to re-derive success from a side effect on `attachedWindow`. (inferred — not stated by the author)

WebViewPool.getOrCreateWebView: revalidate a pooled WebView before handing it back

WebViewPool.swift

Why it matters. The second of the two real attachment sites, covering the SVG path (SVGRenderer is WebViewPool's only consumer). It is also the better-shaped of the two call sites: it reads `existing.window` fresh instead of trusting a cached field.

What to look at. WebViewPool.swift:365-381 (call site) and :74-88 (UIWindowHostState)

Takeaway. `webView.window` already is the source of truth for whether a view is installed in a window hierarchy. A separate cached `attachedWindow` alongside it is state that can only diverge — and diverge silently, in the direction of a false 'still live'.
Rationale. UIWindowHostState is placed in WebViewPool.swift rather than beside the protocol because that file already carries the `#if os(iOS)` block and the WindowSceneProviding seam it sits next to. (inferred — not stated by the author)

WindowAttachmentWiringTests: a structural pin for two iOS-gated call sites

WindowAttachmentWiringTests.swift

Why it matters. 254 lines of source-scanning machinery to assert two `.contains("reattachIfNeeded(")`. It follows this repo's established chokepoint precedent and is honest about its limits — but it re-implements two helpers that ProductionSourceScan already owns, which is the exact duplication CLAUDE.md records as having cost real coverage before.

What to look at. WindowAttachmentWiringTests.swift — functionBody / stripLineComment / braceDelta, plus two pins and a self-test

Takeaway. A source-text pin earns its keep only where a live test genuinely cannot reach — and it should be built from the shared scanning machinery, not a fresh copy of it. A lesson learned in one copy does not reach the other.
Rationale. The call sites are `#if os(iOS)`-gated, so their bodies do not compile into the macOS target that `make test-quick` runs — a live test cannot call into them there. The suite says so explicitly.

WindowAttachmentValidatorTests: seven live tests, deliberately not iOS-gated

WebViewPoolTests.swift

Why it matters. These are the live half of the coverage story. Four pin the truth table, three pin reattachIfNeeded's behaviour: live is a no-op, stale runs detach-then-reattach in that order, and a throwing reattach propagates after detach has already run.

What to look at. WebViewPoolTests.swift:809-925 — MockAttachedHostState + 7 @Test cases

Takeaway. Synchronous @Test bodies in a @MainActor suite are only safe here because nothing in them reaches a WebKit constructor — taint in Tools/check-webkit-test-isolation.py is per member, not per file, so sharing a file with suites that do build WKWebView is harmless.
Open question. Rationale not stated by the author and not inferable from the diff.

Key decisions

Express the liveness check against a platform-free protocol rather than the existing WindowSceneProviding seam.

WindowSceneProviding abstracts a scene you are choosing from (input: candidates, output: a window). AttachedHostState abstracts a window you already hold (input: one attachment, output: a boolean). They are genuinely different seams.

The counter-case is worth stating: isStillLive(_ window: UIWindow?, in scenes: [some WindowSceneProviding]) would have reused the existing protocol and the existing MockWindow/MockWindowScene doubles verbatim. It was rejected because sceneWindows: [UIWindow] is iOS-only and make test-quick runs on macOS — a legitimate reason, documented at RenderingUtilities.swift:5-12, whose price (no coverage of the UIKit derivation) is under-stated.

(inferred — not stated by the author.)
Revalidate on entry only, not for the duration of a render.

Both call sites check once, before the work. A scene closing mid-render still costs the full timeout budget. This addresses T-2133 as filed — the reported symptom is subsequent renders — and avoids taking a dependency on UISceneDidDisconnectNotification. The cost is that it does not subsume T-2134.

(inferred — not stated by the author.)
Keep a detached WebView in the pool slot when re-attach throws, rather than nilling it.

Nilling would discard a warmed WebContent process and force a fresh WKWebView on the next checkout. Keeping it self-heals instead: the next call sees existing.window == nil, judges it stale, and retries the attach. Correct, but it makes the doc comment on getOrCreateWebView (“stored in the pool only after successful window attachment”) half-true.

(inferred — not stated by the author.)
Add a source-scanning structural pin in addition to the live tests.

Explicitly stated in the suite header: this is the T-1943 lesson — a direct-invocation test cannot see missing wiring, because it never asks production code to reach the seam on its own. Precedent: ImageMaterializationChokepointTests, URLChokepointAdoptionTests, WebContentTerminationWiringTests. After the round-2 redesign the pin was narrowed to a single stable identifier per call site rather than the reattach expression's exact spelling, which had made the prior version brittle.

No specs/bugfixes report for this ticket.

specs/bugfixes/ holds ~109 reports, including webviewpool-wrong-scene-window (T-745, the direct ancestor of this bug). This branch adds none. Not a finding: I checked the five most recent bugfix commits on main (T-1744, T-2132, T-2213, T-2219, and the warning sweep) and none of them added one either — the convention has lapsed repo-wide, so this branch is consistent with current practice rather than diverging from it.

Review findings

SeverityAreaFindingResolution
blockerMermaidRenderer.swift:209 — redundant tryThe iOS build emits `warning: no calls to throwing functions occur within 'try' expression`. `reattachIfNeeded` is `rethrows`; MermaidRenderer's `reattach: { self.attachToWindowLegacy() }` is non-throwing and `detach` is typed `() -> Void`, so the call cannot throw and the `try` is dead. Confirmed in a real build: `xcodebuild build -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` reports exactly this one app-target warning; the macOS build reports zero, because the branch is `#if os(iOS)` and macOS never compiles it. This breaks CLAUDE.md's pre-push gate (`make build-ios` must pass with zero warnings) and reintroduces a warning immediately after main's tip commit f2f39672 'Clear every APP-TARGET compiler warning'. The WebViewPool call site is correct — its `reattach: { try self.attachToWindowHierarchy(existing) }` genuinely throws.Drop the `try` at MermaidRenderer.swift:209 (`@discardableResult` is already on the helper, and the wiring pin matches `reattachIfNeeded(` either way, so nothing else changes). Cleaner alternative, which also removes finding 3: give `attachToWindowLegacy` an iOS `throws` that fires when `selectIOSWindow` returns nil, keep the `try`, and delete the follow-on `if attachedWindow == nil { throw ... }`.
majorWebViewPool.swift:74-88 — UIWindowHostState has no test coverage`MockAttachedHostState` supplies both booleans directly, so no test ever exercises the derivation from a real `UIWindow`. `WindowAttachmentWiringTests` only greps for the string `reattachIfNeeded(`. Net effect: rewriting `sceneIsConnected` as `{ window != nil }` — which deletes the entire scene check and reintroduces T-2133 exactly — leaves every test on this branch green. Verified: `UIWindowHostState` appears in `prismTests/` exactly once, inside a doc comment. The justification given in both suites' headers ('make test-quick runs on macOS, where an #if os(iOS) test body never executes') is true only of test-quick: `make test` (Makefile:216-229) runs the whole `prismTests` target on `platform=iOS Simulator` with NO `-only-testing` filter, and CLAUDE.md lists it as required before pushing. The pattern is already proven in the same file — `WebViewPoolIOSWindowSelectionTests` is an `#if os(iOS)` `@MainActor struct` using the existing `MockWindow: UIWindow` / `MockWindowScene` doubles.Add an `#if os(iOS)` `@MainActor struct UIWindowHostStateTests` beside `WebViewPoolIOSWindowSelectionTests`. All three branches are reachable: `UIWindowHostState(window: nil).windowIsPresent == false`; `MockWindow(isKeyWindow: false)` created with `frame: .zero` has no `windowScene`, giving `sceneIsConnected == false` — precisely the 'window alive, scene gone' case; and a window obtained from `selectIOSWindow(from: UIApplication.shared.connectedScenes...)` in the iOS test host must report `isStillLive == true`. Then narrow the two doc comments to say 'the macOS test-quick loop' rather than implying iOS test bodies never run.
minorMermaidRenderer.swift:51 — attachedWindow is redundant state`attachedWindow` duplicates `webView.window`. Its doc comment claims it is 'used to avoid re-attaching to the same window', which is false — `attachToWindowLegacy` never reads it and unconditionally calls `addSubview`. After this change its only readers are the two new lines (:210 and :217). WebViewPool needs no such field: it reads `existing.window` fresh, which is the correct shape. The redundancy is what forces the two structurally different detach closures (Mermaid's nils the shadow field, the pool's does not) and the leaky post-check at :217, where the caller re-derives success from a closure's side effect because `reattachIfNeeded` answers 'did the closures run', not 'is it attached now'. The divergence is not reachable today — no iOS path removes the WebView from its superview without also nilling `attachedWindow` — but a false 'still live' is exactly the failure class T-2133 is about.Drop `attachedWindow`; pass `UIWindowHostState(window: webView.window)` at both sites. Combined with the `throws` variant of finding 1, both call sites become structurally identical and the post-check disappears.
minorWindowAttachmentWiringTests.swift — re-implements two ProductionSourceScan helpers`stripLineComment(_:)` is a character-for-character re-implementation of `ProductionSourceScan.stripComment(_:)` (Support/ProductionSourceScan.swift:145) — same quote/escape state machine, same semantics. `braceDelta(in:)` is `ProductionSourceScan.unclosedParens(in:)` (line 98) with the bracket pair swapped; the file's own comment says it 'mirrors' it. The header claims the suite 'reuses ProductionSourceScan's source-root discovery rather than a fresh copy of that logic' — it reuses only `productionSourceRoot`, while forking the two parsing helpers. CLAUDE.md records the concrete cost of exactly this: 'the URL scan had already learned that .init(string:) is URL(string:), and the copied image scan was blind to .init(data: all over again.'Call `ProductionSourceScan.stripComment` directly, and add a brace-counting variant alongside `unclosedParens` in `ProductionSourceScan` (or generalise it to take a bracket pair). Delete both local copies and the self-test that exists only to validate them. Fixing the shared helper also fixes finding 6 in one place.
minorWebViewPool.swift:363-365 — doc comment invariant is now half-true'The WebView is stored in the pool only after successful window attachment.' Still true for the creation path, but the reuse path can now leave a detached WebView in `pool[index]`: `detach()` runs, `attachToWindowHierarchy(existing)` throws, and `existing` stays in the slot. Behaviourally benign and self-healing — verified there is no other reader of `pool[index]` (only `clear()` and the process-termination handler, both of which just nil it) — but a future reader relying on the invariant could be misled.Extend the comment to cover the revalidation path, or nil the slot in a `catch` before rethrowing. Prefer the comment: nilling would discard a warmed WebContent process for no correctness gain.
minorMermaidRenderer.swift:218 — unlocalised English literal duplicated`MermaidError.renderFailed("No window available for webView attachment")`. `renderFailed` interpolates its message into a catalog string (`error.mermaid.renderFailed`) and `MermaidDiagramSheet` surfaces it verbatim, so a hard-coded English fragment lands inside a localised sentence — structurally the shape CLAUDE.md bans. The literal is pre-existing on the macOS branch (:226) and consistent with siblings, so it would not fail review on its own; the objection is that this PR duplicates it rather than reusing it.Add `MermaidError.noWindowHierarchy` with `String(localized: "error.mermaid.noWindowHierarchy", ...)`, copying the pattern `WebViewPoolError.noWindowHierarchy` already uses (`error.webView.noWindowHierarchy`). Both throw sites then collapse to one.
minorRenderingUtilities.swift:5-65 — wrong home for the new typesThe new protocol and enum sit above the file's own doc comment (line 68: 'Shared rendering helpers used by SVGRenderer, BackgroundDiagramRenderer, and MermaidRenderer'), which is attached to `enum RenderingUtilities` and no longer describes the file. Repo convention cuts against the placement: a testability seam gets its own file (`KeyValueStoreProtocol.swift`), a single-purpose service gets its own file (`BoundedFileRead.swift`, `ImageMemoryGuard.swift`), and a seam inseparable from one consumer lives beside it (`WindowSceneProviding` is in `WebViewPool.swift`). The current split — protocol in one file, its only production conformance in another, with cross-referencing comments in both — is a symptom of the wrong home rather than a design.Move all three types to `prism/Services/WindowAttachmentValidator.swift`, with `UIWindowHostState` under `#if os(iOS)`. Note: `WindowAttachmentWiringTests.sentinel` is `Services/RenderingUtilities.swift` and would need no change (the sentinel only has to exist), but the comment explaining the choice would.
nitRenderingUtilities.swift:55-65 — @discardableResult Bool has no consumerBoth production call sites discard the return value, and the tests do not need it: `reattachIfNeededSkipsWhenLive` already asserts both spies are unfired, and `reattachIfNeededRunsInOrderWhenStale` already asserts `callOrder == ["detach", "reattach"]`. `didReattach` is a third assertion of the same fact.Either make it `Void`, or give it the one job that would earn its keep: feed it into `getOrCreateWebView`'s returned `isNew` flag. `SVGRenderer` keys `templateReady` on `ObjectIdentifier(webView)`, so a reattached WebView is treated as already-templated — if WebKit ever discards the page on unparenting, the injection would target an empty document. A narrow window (a real jettison fires `webViewWebContentProcessDidTerminate`, which is handled), but it is the one place the Bool means something.
nitWindowAttachmentWiringTests.swift — braceDelta desyncs on """ literalsThree quotes in a `"""` fence toggle `inQuote` an odd number of times, so a multi-line string literal inside a scanned function desyncs brace counting and silently over-runs the body to end-of-file. Verified by mutation: injecting a `"""` literal into `getOrCreateWebView` grows the isolated body from 22 lines to 198. No impact today — neither scanned function contains one, and `reattachIfNeeded(` appears nowhere else in either file — and `ProductionSourceScan` shares the limitation, so 'mirrors' is a fair claim. It is the phrase 'ignores anything inside a string literal' that overstates what the code delivers.Fold into finding 4: share one helper, then either handle `"""` or narrow the doc comment to 'single-line string literals'.
nitCHANGELOG.md — 'before every render' reads stronger than the guaranteeBoth sites revalidate on entry, so a scene closing mid-render is not covered. Accurate for Mermaid ('before every render'); for the pool it is really 'before every checkout'. Placement, tier ([Unreleased] > Fixed, first entry) and plain-language style all match house convention, and the claim that both Mermaid and SVG are covered is correct — `SVGRenderer` is `WebViewPool`'s only consumer and `MermaidRenderer` is reached via `DiagramCache`.Optional: 'before each render begins'. Not worth a rewrite of an otherwise well-written entry.
nitdocs/agent-notes/webview-pool.md — 'iOS Window Selection (T-745)' section now incompleteThe note documents the selection rule and states 'Store only after successful window attachment', both of which this change qualifies. It says nothing about revalidation. Per CLAUDE.md a stale note is worse than no note, and this is an existing note whose subject this PR directly extends.Add two or three lines to the T-745 section: attachment is revalidated before each render/checkout via `WindowAttachmentValidator`, and the reuse path can leave a detached WebView in the slot when re-attach fails (self-healing).
nitOut of scope — pre-existing staleness noticed while reviewing`BackgroundDiagramRenderer` no longer exists in the tree, but is still named in `RenderingUtilities.swift:68`, `CLAUDE.md`'s architecture section and source listing, and `docs/agent-notes/webview-pool.md`. `WebViewPool`'s only remaining consumer is `SVGRenderer`. Not introduced by this branch and not this branch's job to fix.Worth a separate chore ticket; do not expand this PR.

Per-file diffs

Click to expand.

prism/Services/RenderingUtilities.swift Modified +63 / -0
diff --git a/prism/Services/RenderingUtilities.swift b/prism/Services/RenderingUtilities.swiftindex 87bde6f1..33051186 100644--- a/prism/Services/RenderingUtilities.swift+++ b/prism/Services/RenderingUtilities.swift@@ -2,6 +2,69 @@ import Foundation import WebKit import OSLog +/// Describes whether a previously attached render-host window can still be+/// used. Kept free of UIKit types so the revalidation decision in+/// `WindowAttachmentValidator.isStillLive` is unit-testable on macOS even+/// though only iOS ever needs it in production — macOS instead hosts+/// renderers in a dedicated off-screen `NSWindow` that is retained for the+/// process lifetime and never needs revalidation. The production conformance+/// (`UIWindowHostState`, wrapping a real `UIWindow`) lives in+/// `WebViewPool.swift` under `#if os(iOS)`.+@MainActor+protocol AttachedHostState {+    /// Whether the window reference is still alive (not deallocated).+    var windowIsPresent: Bool { get }+    /// Whether the window's owning scene is still connected to the app.+    var sceneIsConnected: Bool { get }+}++/// Pure decision logic for whether a render host attached on iOS is still+/// live, or must be replaced by re-attaching to a currently-connected+/// scene's window (T-2133). On iPad, a scene can close at any time while+/// another stays open — a renderer or pooled WebView that attached to the+/// closed scene's window keeps a technically-non-nil `UIWindow` reference+/// (until it deallocates), but JavaScript execution against it silently+/// fails or times out because the window is no longer part of any live+/// scene.+@MainActor+enum WindowAttachmentValidator {+    /// True when a previously attached host window is still safe to reuse:+    /// the window itself has not been deallocated, and its owning scene is+    /// still connected to the app.+    static func isStillLive(_ state: AttachedHostState) -> Bool {+        state.windowIsPresent && state.sceneIsConnected+    }++    /// The platform-agnostic half of the T-2133 fix: decide whether `state`+    /// is stale and, if so, detach then reattach in that order. Both+    /// production call sites (`MermaidRenderer.renderInternal`,+    /// `WebViewPool.getOrCreateWebView`) differ only in what "the window" and+    /// "detach"/"reattach" mean for their own WebView — the decide-then-act+    /// sequencing itself is identical, so it is expressed once here and is+    /// directly live-testable with spy closures (no UIKit required), rather+    /// than only checkable by scanning each call site's source text.+    ///+    /// - Parameters:+    ///   - state: The current attachment to validate.+    ///   - detach: Invoked first when `state` is stale, to release the old+    ///     attachment (e.g. `webView.removeFromSuperview()`).+    ///   - reattach: Invoked second when `state` is stale, to attach to a+    ///     currently-live host. Any throw propagates to the caller.+    /// - Returns: `true` if `state` was stale and `detach`/`reattach` ran;+    ///   `false` if `state` was already live and neither closure ran.+    @discardableResult+    static func reattachIfNeeded(+        _ state: AttachedHostState,+        detach: () -> Void,+        reattach: () throws -> Void+    ) rethrows -> Bool {+        guard !isStillLive(state) else { return false }+        detach()+        try reattach()+        return true+    }+}+ /// Shared rendering helpers used by `SVGRenderer`, `BackgroundDiagramRenderer`, /// and `MermaidRenderer`. The helpers cover three patterns that were previously /// duplicated across the three renderers: racing an async operation against a
prism/Services/MermaidRenderer.swift Modified +21 / -2
diff --git a/prism/Services/MermaidRenderer.swift b/prism/Services/MermaidRenderer.swiftindex a49801e2..591d2027 100644--- a/prism/Services/MermaidRenderer.swift+++ b/prism/Services/MermaidRenderer.swift@@ -197,8 +197,27 @@ final class MermaidRenderer: NSObject {     }      private func renderInternal(source: String, themeConfig: MermaidThemeConfig?) async throws -> String {-        // Ensure webView is attached to a window (required for JS execution on macOS)-        #if os(macOS)+        // Ensure webView is attached to a live window before every render.+        #if os(iOS)+        // The window attached at init (or during a previous render) may+        // have gone away since: the user can close an iPad scene at any+        // time while another stays open, leaving `attachedWindow` pointing+        // at a window that is no longer part of any connected scene.+        // Revalidate and re-attach to a currently connected scene when+        // stale — otherwise JavaScript execution silently fails or times+        // out (T-2133).+        try WindowAttachmentValidator.reattachIfNeeded(+            UIWindowHostState(window: attachedWindow),+            detach: {+                self.webView.removeFromSuperview()+                self.attachedWindow = nil+            },+            reattach: { self.attachToWindowLegacy() }+        )+        if attachedWindow == nil {+            throw MermaidError.renderFailed("No window available for webView attachment")+        }+        #elseif os(macOS)         if renderWindow == nil {             attachToWindowLegacy()         }
prism/Services/WebViewPool.swift Modified +31 / -0
diff --git a/prism/Services/WebViewPool.swift b/prism/Services/WebViewPool.swiftindex d4cae5b2..704261bc 100644--- a/prism/Services/WebViewPool.swift+++ b/prism/Services/WebViewPool.swift@@ -71,6 +71,24 @@ extension UIWindowScene: WindowSceneProviding {     var sceneActivationState: UIScene.ActivationState { activationState }     var sceneWindows: [UIWindow] { windows } }++/// Production `AttachedHostState` for a `UIWindow` a renderer previously+/// attached its WebView to. Used to detect a stale attachment left behind+/// when the window's owning scene disconnects — e.g. the user closes an+/// iPad scene while another stays open (T-2133). `window` is weak: once the+/// window itself deallocates, `windowIsPresent` reports it directly rather+/// than relying on the scene check.+@MainActor+struct UIWindowHostState: AttachedHostState {+    weak var window: UIWindow?++    var windowIsPresent: Bool { window != nil }++    var sceneIsConnected: Bool {+        guard let scene = window?.windowScene else { return false }+        return UIApplication.shared.connectedScenes.contains(scene)+    }+} #endif  // MARK: - WebViewPool@@ -347,6 +365,19 @@ final class WebViewPool: NSObject, WKNavigationDelegate {     /// The WebView is stored in the pool only after successful window attachment.     private func getOrCreateWebView(at index: Int) throws -> (WKWebView, Bool) {         if let existing = pool[index] {+            #if os(iOS)+            // A pooled WebView may have been attached to a scene that has+            // since closed (e.g. the user closed an iPad scene while+            // another stayed open). Revalidate before reuse and re-attach+            // to a currently connected scene when stale — otherwise+            // JavaScript execution against the detached view silently+            // fails or times out (T-2133).+            try WindowAttachmentValidator.reattachIfNeeded(+                UIWindowHostState(window: existing.window),+                detach: { existing.removeFromSuperview() },+                reattach: { try self.attachToWindowHierarchy(existing) }+            )+            #endif             return (existing, false)         }         let webView = createWebView()
prismTests/WebViewPoolTests.swift Modified +117 / -0
diff --git a/prismTests/WebViewPoolTests.swift b/prismTests/WebViewPoolTests.swiftindex 534a4e4d..526b1ccd 100644--- a/prismTests/WebViewPoolTests.swift+++ b/prismTests/WebViewPoolTests.swift@@ -806,3 +806,120 @@ final class MockWindow: UIWindow {     } } #endif++// MARK: - Window Attachment Revalidation Tests (T-2133)++/// Tests for `WindowAttachmentValidator.isStillLive`, the pure decision that+/// backs "should a renderer/pooled WebView re-attach before this render?".+///+/// Deliberately **not** `#if os(iOS)`-gated: `make test-quick` runs on+/// macOS, where an `#if os(iOS)` test body never executes, so the+/// revalidation decision is expressed against the platform-agnostic+/// `AttachedHostState` protocol (see `RenderingUtilities.swift`) precisely+/// so it can be exercised here without any UIKit dependency.+///+/// Regression test for T-2133: MermaidRenderer and WebViewPool attached a+/// hidden WebView to an iPad scene's window once, at init/first render, and+/// never revalidated that attachment. If the user closed that scene while+/// another stayed open, later renders reused a WebView attached to a window+/// that was no longer part of any connected scene — JavaScript execution+/// against it silently failed or timed out.+@MainActor+struct WindowAttachmentValidatorTests {++    /// Test double for `AttachedHostState` — no UIKit types involved, so+    /// this exercises the same decision logic production code applies via+    /// `UIWindowHostState` without requiring a live `UIWindow`/`UIWindowScene`.+    struct MockAttachedHostState: AttachedHostState {+        let windowIsPresent: Bool+        let sceneIsConnected: Bool+    }++    @Test("Live when window is present and its scene is connected")+    func liveWhenPresentAndConnected() {+        let state = MockAttachedHostState(windowIsPresent: true, sceneIsConnected: true)+        #expect(WindowAttachmentValidator.isStillLive(state) == true)+    }++    @Test("Not live when the window has deallocated")+    func notLiveWhenWindowGone() {+        let state = MockAttachedHostState(windowIsPresent: false, sceneIsConnected: true)+        #expect(WindowAttachmentValidator.isStillLive(state) == false)+    }++    @Test("Not live when the window's scene has disconnected (T-2133 core case)")+    func notLiveWhenSceneDisconnected() {+        // This is the exact T-2133 scenario: the scene the window belonged+        // to was closed (e.g. an iPad scene), but the weak `UIWindow`+        // reference has not deallocated yet.+        let state = MockAttachedHostState(windowIsPresent: true, sceneIsConnected: false)+        #expect(WindowAttachmentValidator.isStillLive(state) == false)+    }++    @Test("Not live when both window and scene are gone")+    func notLiveWhenBothGone() {+        let state = MockAttachedHostState(windowIsPresent: false, sceneIsConnected: false)+        #expect(WindowAttachmentValidator.isStillLive(state) == false)+    }++    // MARK: - reattachIfNeeded++    /// Live wiring pin for `WindowAttachmentValidator.reattachIfNeeded`, the+    /// platform-agnostic "decide, then detach+reattach" helper both+    /// `MermaidRenderer.renderInternal` and `WebViewPool.getOrCreateWebView`+    /// now delegate to. Unlike those two call sites (`#if os(iOS)`-gated+    /// internally, so only pinnable on macOS via `WindowAttachmentWiringTests`'+    /// source scan), this helper itself is plain Swift with no UIKit+    /// dependency, so it can be exercised for real here with spy closures —+    /// no text scan needed for the behaviour these tests cover.++    private struct SpyReattachError: Error {}++    @Test("reattachIfNeeded does nothing when the state is already live")+    func reattachIfNeededSkipsWhenLive() throws {+        let state = MockAttachedHostState(windowIsPresent: true, sceneIsConnected: true)+        var detachCalled = false+        var reattachCalled = false++        let didReattach = try WindowAttachmentValidator.reattachIfNeeded(+            state,+            detach: { detachCalled = true },+            reattach: { reattachCalled = true }+        )++        #expect(didReattach == false)+        #expect(detachCalled == false)+        #expect(reattachCalled == false)+    }++    @Test("reattachIfNeeded detaches then reattaches, in order, when the state is stale")+    func reattachIfNeededRunsInOrderWhenStale() throws {+        // The T-2133 core case: window still present, scene disconnected.+        let state = MockAttachedHostState(windowIsPresent: true, sceneIsConnected: false)+        var callOrder: [String] = []++        let didReattach = try WindowAttachmentValidator.reattachIfNeeded(+            state,+            detach: { callOrder.append("detach") },+            reattach: { callOrder.append("reattach") }+        )++        #expect(didReattach == true)+        #expect(callOrder == ["detach", "reattach"])+    }++    @Test("reattachIfNeeded propagates the reattach closure's throw after detaching")+    func reattachIfNeededPropagatesReattachError() {+        let state = MockAttachedHostState(windowIsPresent: false, sceneIsConnected: false)+        var detachCalled = false++        #expect(throws: SpyReattachError.self) {+            try WindowAttachmentValidator.reattachIfNeeded(+                state,+                detach: { detachCalled = true },+                reattach: { throw SpyReattachError() }+            )+        }+        #expect(detachCalled == true)+    }+}
prismTests/WindowAttachmentWiringTests.swift Added +254 / -0
diff --git a/prismTests/WindowAttachmentWiringTests.swift b/prismTests/WindowAttachmentWiringTests.swiftnew file mode 100644index 00000000..18c447c9--- /dev/null+++ b/prismTests/WindowAttachmentWiringTests.swift@@ -0,0 +1,254 @@+//+//  WindowAttachmentWiringTests.swift+//  prismTests+//+//  T-2133 follow-up: pins the CALL SITES, not just the decision.+//++import Foundation+import Testing+@testable import prism++/// `WindowAttachmentValidatorTests` (in `WebViewPoolTests.swift`) pins both+/// halves of the decision production code applies: `isStillLive` (the pure+/// check) and, since the round-2 review redesign, `reattachIfNeeded` (the+/// platform-agnostic "decide, then detach+reattach" behaviour), the latter+/// exercised live with spy closures. That leaves exactly the gap T-1943+/// already taught this codebase about: a direct-invocation test cannot see+/// missing wiring, because it never asks production code to reach the seam+/// on its own. Deleting the `WindowAttachmentValidator.reattachIfNeeded(...)`+/// call from either production call site — `MermaidRenderer.renderInternal`+/// or `WebViewPool.getOrCreateWebView` — would leave `WindowAttachmentValidatorTests`+/// green while reintroducing the exact T-2133 bug (a WebView left attached to+/// a closed iPad scene's window).+///+/// A *live* wiring pin — handing `WebViewPool`/`MermaidRenderer` a fake stale+/// host state and observing that the re-attach path runs — is not buildable+/// here for the call sites themselves: the `if !isStillLive(...) { detach();+/// reattach() }` sequencing they used to inline is `#if os(iOS)`-gated at+/// each site (the doc comment on `AttachedHostState` in+/// `RenderingUtilities.swift` explains why — macOS hosts renderers in a+/// dedicated `NSWindow` that never needs revalidation), so that code path+/// doesn't compile into the macOS test target `make test-quick` runs at all.+/// The decide-then-act *behaviour* itself is no longer iOS-only, though: it+/// now lives in the platform-agnostic `WindowAttachmentValidator.reattachIfNeeded`,+/// which is exactly what `WindowAttachmentValidatorTests` exercises live. What's+/// left for THIS suite to pin is narrower and genuinely can't be tested any+/// other way: that each `#if os(iOS)` call site still actually calls that+/// helper, since neither call site compiles on macOS for a live test to call+/// into directly.+///+/// So this is the structural alternative CLAUDE.md documents for exactly this+/// situation (`ImageMaterializationChokepointTests`,+/// `URLChokepointAdoptionTests`): it isolates each production function's body+/// by name and asserts it still calls `WindowAttachmentValidator.reattachIfNeeded`.+/// Because the helper itself is now the thing that's live-tested, a single+/// stable identifier per call site is sufficient here — there's no longer a+/// need to also pin the reattach expression's exact spelling (a local+/// variable name, argument list, or line-wrapping), which is what made the+/// prior two-assertion version of this suite brittle. It reuses+/// `ProductionSourceScan`'s source-root discovery rather than a fresh copy of+/// that logic.+///+/// ## What it cannot see+///+/// A textual scan cannot prove the call is reached at the right *time*+/// (before every render/checkout, not just somewhere in the function), only+/// that the call is still present in the function body. Reordering rather+/// than deleting the call would not be caught — nor would passing the wrong+/// state, or swapping the detach/reattach closures for no-ops; those are+/// exactly what `WindowAttachmentValidatorTests`' live `reattachIfNeeded`+/// tests pin instead. The live `WindowAttachmentValidatorTests` and+/// `WebContentTerminationWiringTests` remain the model for a platform where+/// exercising the real call site is possible; this suite exists because, on+/// macOS, the call sites themselves are not reachable.+struct WindowAttachmentWiringTests {++    // MARK: - Function body isolation++    /// A file under `prism/` that must exist for source-root discovery to+    /// have found the right tree. `RenderingUtilities.swift` hosts+    /// `WindowAttachmentValidator` itself.+    private static let sentinel = "Services/RenderingUtilities.swift"++    private static func productionSource(_ relativePath: String) throws -> String {+        let root = try #require(+            ProductionSourceScan.productionSourceRoot(sentinel: sentinel),+            "Could not locate the prism/ source tree; the wiring pin cannot run."+        )+        return try String(+            contentsOf: root.appendingPathComponent(relativePath),+            encoding: .utf8+        )+    }++    /// Isolates the source of the function named `name`, from its `func`+    /// line to the matching closing brace, so a match on+    /// `reattachIfNeeded(` elsewhere in the file (a comment, a different+    /// function) cannot satisfy this test. Two things keep the isolation+    /// honest:+    /// - The `func` search requires the identifier to be followed+    ///   immediately by `(` (`"func \(name)("`), so a differently-named+    ///   function that merely shares the same prefix (e.g. a future+    ///   `renderInternalFallback` sorted earlier in the file) cannot hijack+    ///   the match.+    /// - Every line has its `//` line comment stripped (quote-aware, so a+    ///   string literal containing `//` is untouched) before brace counting+    ///   and before the caller's `.contains` check, so a comment that+    ///   happens to mention the identifier being pinned can't satisfy the+    ///   assertion after the real call is gone — the same stale-doc-drift+    ///   concern `ProductionSourceScan.staleMarkers` guards against+    ///   elsewhere in this codebase.+    ///+    /// Brace counting itself ignores anything inside a string literal too,+    /// mirroring `ProductionSourceScan.unclosedParens`, so a future string+    /// literal containing `{`/`}` cannot desync the count.+    private static func functionBody(named name: String, in source: String) throws -> String {+        let lines = source.components(separatedBy: "\n").map(stripLineComment)+        let marker = "func \(name)("+        let startIndex = try #require(+            lines.firstIndex { $0.contains(marker) },+            "Could not find `\(marker)` — has it been renamed, given a different signature, or moved?"+        )++        var depth = 0+        var started = false+        var bodyLines: [String] = []+        for line in lines[startIndex...] {+            depth += braceDelta(in: line)+            started = started || depth > 0+            bodyLines.append(line)+            if started, depth == 0 { break }+        }+        return bodyLines.joined(separator: "\n")+    }++    /// Strips a `//` line comment from `line`, ignoring `//` that appears+    /// inside a string literal. Quote/escape tracking mirrors `braceDelta`.+    private static func stripLineComment(_ line: String) -> String {+        var result = ""+        var inQuote = false+        var escaped = false+        let characters = Array(line)+        var index = 0+        while index < characters.count {+            let character = characters[index]+            if escaped {+                result.append(character)+                escaped = false+            } else if character == "\\" {+                result.append(character)+                escaped = true+            } else if character == "\"" {+                inQuote.toggle()+                result.append(character)+            } else if !inQuote, character == "/", index + 1 < characters.count, characters[index + 1] == "/" {+                break+            } else {+                result.append(character)+            }+            index += 1+        }+        return result+    }++    /// `{` minus `}` in `line`, ignoring anything inside a string literal.+    private static func braceDelta(in line: String) -> Int {+        var delta = 0+        var inQuote = false+        var escaped = false+        for character in line {+            if escaped {+                escaped = false+            } else if character == "\\" {+                escaped = true+            } else if character == "\"" {+                inQuote.toggle()+            } else if !inQuote, character == "{" {+                delta += 1+            } else if !inQuote, character == "}" {+                delta -= 1+            }+        }+        return delta+    }++    // MARK: - The pins++    @Test("MermaidRenderer.renderInternal still calls WindowAttachmentValidator.reattachIfNeeded before rendering")+    func mermaidRendererRevalidatesBeforeRender() throws {+        let source = try Self.productionSource("Services/MermaidRenderer.swift")+        let body = try Self.functionBody(named: "renderInternal", in: source)++        #expect(+            body.contains("reattachIfNeeded("),+            """+            renderInternal must revalidate the attached window via \+            WindowAttachmentValidator.reattachIfNeeded before every render (T-2133). \+            Without this, a WebView left attached to a closed iPad scene's window \+            keeps rendering against it, and JavaScript execution silently fails or \+            times out. The helper's own detach/reattach behaviour is covered live by \+            WindowAttachmentValidatorTests' reattachIfNeeded tests in WebViewPoolTests.swift.+            """+        )+    }++    @Test("WebViewPool.getOrCreateWebView still calls WindowAttachmentValidator.reattachIfNeeded before reuse")+    func webViewPoolRevalidatesBeforeReuse() throws {+        let source = try Self.productionSource("Services/WebViewPool.swift")+        let body = try Self.functionBody(named: "getOrCreateWebView", in: source)++        #expect(+            body.contains("reattachIfNeeded("),+            """+            getOrCreateWebView must revalidate a pooled WebView's window via \+            WindowAttachmentValidator.reattachIfNeeded before reuse (T-2133). Without \+            this, a pooled WebView attached to a closed iPad scene's window is handed \+            back unchanged, producing blank snapshots or timeouts in the still-open \+            scene. The helper's own detach/reattach behaviour is covered live by \+            WindowAttachmentValidatorTests' reattachIfNeeded tests in WebViewPoolTests.swift.+            """+        )+    }++    // MARK: - The isolation helper itself++    /// `functionBody(named:in:)` is the whole mechanism here, so its edges+    /// are pinned directly — the same discipline+    /// `ImageMaterializationChokepointTests.rulesCatchTheSpellingsThatUsedToSlipPast`+    /// applies to its own rules.+    @Test("functionBody isolates only the named function, ignoring string-literal braces, comments, and same-prefixed functions")+    func functionBodyIsolatesOnlyTheNamedFunction() throws {+        let source = """+        struct Example {+            func targetFallback() {+                print("decoy, must not hijack the match for `target`")+            }++            func before() {+                let s = "not { part of before }"+            }++            func target(x: Int) -> Int {+                if x > 0 {+                    return x // reattachIfNeeded( in a comment, must not survive stripping+                }+                return 0+            }++            func after() {+                print("after")+            }+        }+        """++        let body = try Self.functionBody(named: "target", in: source)++        #expect(body.contains("func target("))+        #expect(body.contains("return x"))+        #expect(!body.contains("reattachIfNeeded("))+        #expect(!body.contains("decoy"))+        #expect(!body.contains("before"))+        #expect(!body.contains("after"))+    }+}
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..00b2a68d 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- On iPad, Mermaid and SVG rendering no longer silently fails or times out in a second scene after the scene it first rendered in has been closed (T-2133). The hidden WebViews used for offscreen rendering attached to a scene's window once, at creation, and never checked whether that window was still part of a connected scene before reusing it; closing the original scene left them attached to a dead window while the app kept trying to render through it. Both the mermaid renderer and the shared WebView pool now revalidate that attachment before every render and re-attach to a currently connected scene's window when it has gone stale. - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.

Things to double-check

The verification I ran, and what it does not cover.

Executed against this branch: make lint (0 violations, 556 files), make verify-test-isolation (OK, plus its own 43 unit tests), xcodebuild build for macOS (0 warnings) and for iOS Simulator (1 warning — finding 1). I did not run the full test suite, per instruction; the targeted suites were reported as 10/10 passing in an earlier run, and I re-ran them once mid-review (also green, though see the next item).

Not covered by anything I ran: the actual iPad multi-scene behaviour. No test on this branch or in the repo disconnects a scene, so the end-to-end fix is validated by construction and by reading, not by execution.

Concurrent worktree contention distorted one measurement.

Another job was running mutation checks and xcodebuild against this same worktree throughout the review. One consequence is worth recording: my first mutation run appeared to show the structural pin passing with the call deleted — but the other job had reverted the working-tree edit between my read and the test run, so the pin was scanning unmutated source. I re-did the check by replicating the pin's algorithm against committed source instead, which is immune to that. Conclusion after redoing it: the pin is not vacuous. A first-pass reviewer trusting the original run would have concluded the opposite.

make build-macos also failed once with SPM checkout lock errors for the same reason (it depends on clean, which deletes the shared DerivedData); both builds were re-run against an isolated -derivedDataPath.

Whether the two majors are really two, or one.

They have the same root: the branch reasoned about what make test-quick compiles, and stopped there. The redundant try is invisible on macOS; the untested UIWindowHostState is unreachable on macOS. Both are caught the moment the iOS target is compiled and run — which make build-ios and make test both do, and which CLAUDE.md already requires before pushing. Fixing them is cheap; the thing worth carrying forward is that a macOS-only inner loop systematically hides #if os(iOS) defects.

For whoever picks up T-2134.

No conflict, but two things to know. First, getOrCreateWebView's reuse path can now leave a detached but present WebView in pool[index] after a failed re-attach — if T-2134's fix responds to a snapshot timeout by inspecting or recycling slots, that state is reachable. Second, this fix does not make snapshots bounded: it removes one cause of a hang (checking out against a dead window), while a scene closing mid-render still leaves captureSnapshot awaiting takeSnapshot with no timeout race, unlike awaitNavigation.