prism branch T-1541/bugfix-svgwebviewtests-offmain-crash commits 3 files 3 touched lines +257 / -53

Pre-push review: T-1541/bugfix-svgwebviewtests-offmain-crash

Test-only fix for the last remaining T-1541 crasher: SVGWebViewTests constructing WKWebView off the main thread under full-suite load, killing the shared test host and cascading ~1,900 queued tests. Reviewed for PR #332.

At a glance

  • Root cause finally pinned after two failed annotation-level attempts: a synchronous @MainActor test has no hop-on-entry, and swift-testing's caller-inheriting (nonisolated(nonsending)) thunks ran the sync bodies on the cooperative pool under full-run load — WebKit's main-thread RELEASE_ASSERT then killed the host.
  • Fix is runtime-enforced, not annotation-hoped: WebKit-touching tests are now async and route construction through one suite-private withWebView helper wrapping await MainActor.run { } — the crash becomes impossible by construction.
  • SVGWebViewTests was the only suite constructing WebKit objects in sync test bodies; every other live-WebKit suite touches WebKit from async code, which is why only this one crashed.
  • Two zero-value tests deleted during this review: an always-true is WKWebView assertion and a mislabeled duplicate of an existing coordinator test.
  • The -skip-testing:prismTests/SVGWebViewTests workaround is obsolete once this merges; agent notes and memory updated.
  • Bug is a probabilistic scheduling race — a green run is necessary but not sufficient evidence; the structural argument carries the proof (documented in the bugfix report).

Verdict

Ready to merge

Sound, structurally-guaranteed fix with zero production impact. Three parallel review agents (reuse, quality, efficiency) found no major issues; all four minor/nit findings were fixed in a follow-up commit on the branch. Full make test-quick with the previously-skipped suite included runs green with zero cascade entries (one unrelated load-timing flake, verified passing on rerun). GitHub CI failures on the PR are the known account-level Actions billing block, not code.

Review findings

5 raised · 4 fixed · 1 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism's test suite has almost 4,000 automated tests that all run inside one shared "test host" program. A few tests create a real WebKit web view (the engine Safari uses) to check it is configured correctly. WebKit has a hard rule: its setup may only happen on the app's main thread — if it happens anywhere else, WebKit deliberately crashes the whole program rather than continue in a corrupt state.

These tests were supposed to run on the main thread — they were labelled to do so — but under the heavy load of a full test run, the test framework sometimes ran them on a background thread anyway. WebKit then killed the shared host, and every test still queued (~1,900) was falsely reported as a failure. This change makes those tests take a route that is guaranteed at runtime to reach the main thread, so the crash cannot happen any more. Two tests that checked nothing useful were also removed.

Why It Matters

Before this fix, a full test run could collapse into thousands of fake failures, making it impossible to tell whether your own change broke something. People worked around it by skipping this test group entirely. Now the full suite runs to completion reliably.

Key Concepts

  • Main thread: the one thread allowed to touch the UI and WebKit's setup — the only counter in the shop where certain transactions may legally happen.
  • @MainActor annotation: a label telling the Swift compiler "this code belongs on the main thread". For ordinary (synchronous) functions it is a compile-time promise, not a runtime guard — a sign on a door, not a lock.
  • MainActor.run: an explicit runtime request — "run this block on the main thread, wherever I am now." That is the lock.
  • Cascade: one crash of the shared host takes down every test still queued behind it.

Changes Overview

Three files: prismTests/SVGWebViewTests.swift (the fix), docs/agent-notes/development-tooling.md (retires the skip-the-suite guidance), and specs/bugfixes/svgwebviewtests-offmain-crash/report.md (investigation report). Production code untouched.

Tests constructing a live WKWebView via SVGWebView.createWebView() are now async and route construction through one suite-private helper, withWebView, which wraps construction and the caller's assertions in await MainActor.run { }. The suite doc comment records the actual failure mechanism. Two tests were deleted during pre-push review: one asserted webView is WKWebView (statically always true), the other had become an exact duplicate of an existing coordinator test once its unused web-view construction was removed.

Implementation Approach

The suite was already @MainActor (PR #330), yet crash reports kept showing test bodies on the cooperative pool. The mechanism: a synchronous actor-isolated function has no hop-on-entry — isolation is realized only if the caller switches executors first. The caller is swift-testing's macro-generated thunk, compiled in a target with Swift 5 language mode, Approachable Concurrency, and no default actor isolation. Those thunks are nonisolated(nonsending) — they inherit the caller's executor, which under full-suite load is the runner's cooperative pool. Swift 5 mode adds no runtime enforcement, so the mis-hop was silent until WebKit's RELEASE_ASSERT fired.

The fix uses two runtime-enforced mechanisms: an actor-isolated async function switches to its actor's executor on entry as part of the ABI (emitted in the callee, caller-independent), and MainActor.run submits its closure to the main-actor executor explicitly. Both at once is deliberate belt-and-braces after two annotation-only fixes failed.

Trade-offs

  • Fix the target asymmetry instead (SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor on prismTests, or Swift 6 mode): arguably the root fix, but it changes the isolation of ~3,900 tests at once — rejected for blast radius, noted as future work.
  • Keep skipping the suite: permanently loses coverage of security-relevant configuration (the non-persistent data store).
  • #require(Thread.isMainThread) guard: fails legibly instead of crashing, but leaves the tests flaky whenever the mis-hop occurs; MainActor.run both fixes and protects.
  • The inner MainActor.run is technically redundant when the async hop works — accepted cost; the redundancy is the point.

Technical Deep Dive

The diagnostic crux was distinguishing "isolation annotation present" from "isolation realized". Post-#330 crash reports showed the faulting thread as com.apple.root.user-initiated-qos.cooperative with WKWebViewConfiguration.init()WebKit::InitializeWebKit2()runInitializationCode() frames — the trap had merely moved earlier in WebKit's init path (from WebsiteDataStore::createNonPersistent/allDataStores). The Builtin.ImplicitActor parameter in the thunk frames identifies the caller-inheriting (nonisolated(nonsending)) calling convention from Approachable Concurrency. The asymmetry that localized the bug: this was the only suite constructing WebKit objects in synchronous test bodies; every other live-WebKit suite touches WebKit from async code and never crashed. The bug is load-dependent — 20/20 in isolation, a passing Thread.isMainThread probe in isolation, and one non-crashing full baseline run during this investigation — so a deterministic red test is impossible; red/green is run-level and the structural argument carries the proof burden.

Architecture Impact

Zero production impact. The suite gains a single choke point (withWebView) through which any future WebKit-touching test must pass, turning the doc-comment rule ("WebKit only from async tests") into an enforcement surface. The WKWebView never escapes the main-actor closure, which also sidesteps future sendability diagnostics when the target migrates to Swift 6 mode. The remaining synchronous tests were audited: struct memberwise init, a plain NSObject-subclass coordinator (no WebKit init on construction), and a pure static function — none can trap off-main.

Potential Issues

  • The underlying target asymmetry (SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor on the app target only, Swift 5 mode everywhere) remains. Any new sync test calling an implicitly-main-actor production API off-main is the same bug waiting to recur; the durable fix is a target-wide isolation/language-mode migration.
  • If the nonisolated(nonsending) thunk behavior is a toolchain bug rather than intended semantics, a future Xcode could change scheduling — this fix is immune either way, which is why the redundant layer stays.
  • Two load-flaky long-timeout scroll tests and one RawSourceViewModelTests timing flake surfaced during the three full runs; all unrelated, all pass on rerun, worth a ticket if they recur.

Important changes — detailed

SVGWebViewTests: withWebView helper — runtime-enforced main-actor WebKit construction

prismTests/SVGWebViewTests.swift

Why it matters. This is the fix. Every live WKWebView construction in the suite now flows through one async helper wrapping MainActor.run, so the off-main WebKit init that killed the test host is impossible by construction, independent of language mode, thunk semantics, or scheduler load.

What to look at. prismTests/SVGWebViewTests.swift: withWebView + the three tests using it

Takeaway. A sync @MainActor function is a compile-time promise with no hop-on-entry; when a hard main-thread RELEASE_ASSERT is at stake, use an async function (ABI hop) plus MainActor.run (runtime submission) rather than trusting annotations.
Rationale. Two prior annotation-level fixes (#330 adding @MainActor) only moved the trap; the branch deliberately chose mechanisms enforced at runtime. The helper form came out of this review: it deduplicates the four-times-repeated scaffold and gives the suite's rule a single enforcement surface.

SVGWebViewTests: suite doc comment records the real mechanism

prismTests/SVGWebViewTests.swift

Why it matters. The previous doc comment claimed @MainActor alone was load-bearing — which the crash reports disproved. The rewrite prevents a future 'simplification' from reintroducing the crash by explaining exactly why the obvious-looking annotation is insufficient here.

What to look at. prismTests/SVGWebViewTests.swift:24-48 (suite doc comment)

Takeaway. When a fix's natural refactor would silently reintroduce the bug, the defence is a doc comment stating the mechanism, not just the rule.
Rationale. Three failed attempts across two PRs show this exact misunderstanding recurs; the comment is the cheapest guard against attempt four.

Two zero-value tests deleted

prismTests/SVGWebViewTests.swift

Why it matters. createWebViewReturnsWKWebView asserted a statically-always-true 'is' check (cost: one live WKWebView, value: none). htmlGenerationIncludesSVGContent, after losing its dead createWebView() call, exactly duplicated coordinatorInitializesWithEmptyLastSVG under a name describing something it never tested.

What to look at. prismTests/SVGWebViewTests.swift (deletions in 858e4c8)

Takeaway. An always-true assertion is worse than no test: it implies coverage that does not exist. Given the return type is WKWebView, 'x is WKWebView' can never fail.
Rationale. Raised by the quality and efficiency review agents; construction success remains proven by the data-store test, so no coverage is lost.

Bugfix report: full investigation record with honest verification caveat

specs/bugfixes/svgwebviewtests-offmain-crash/report.md

Why it matters. Documents the Five-Whys chain from crash-report frames to the nonisolated(nonsending) thunk mechanism, and states explicitly that a green run is necessary-but-not-sufficient because the bug is a scheduling race — the structural argument is the proof.

What to look at. specs/bugfixes/svgwebviewtests-offmain-crash/report.md (new file, 193 lines)

Takeaway. For probabilistic race bugs, record why red/green is run-level and what the actual proof obligation is — otherwise a future reader mistakes a lucky green baseline for evidence the bug never existed.
Rationale. T-1541's history is littered with fixes that 'worked' because the race did not fire during verification; the report makes that failure mode explicit.

Agent note: skip-the-suite workaround retired

docs/agent-notes/development-tooling.md

Why it matters. The note previously instructed every future session to run with -skip-testing:prismTests/SVGWebViewTests; leaving it stale would keep ~20 tests skipped forever after the fix merges.

What to look at. docs/agent-notes/development-tooling.md:10

Takeaway. A fix that leaves its workaround documented is not finished; stale operational notes actively outlive the bugs they describe.
Rationale. Repo convention: agent notes must be corrected the moment they become wrong, because future sessions act on them verbatim.

Key decisions

Belt-and-braces: async hop AND MainActor.run, not either alone.

An actor-isolated async test already hops on entry as ABI, making the inner MainActor.run technically redundant. It stays because two annotation-level fixes already failed on subtle semantics: MainActor.run is a runtime submission that holds even if the thunk calling convention changes in a future toolchain. Cost is one executor enqueue across three tests — noise at full-run scale (efficiency agent: 'do not change').

Do not fix the target-level isolation asymmetry on this branch.

Giving prismTests SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (or Swift 6 language mode) is plausibly the root fix, but changes the default isolation of ~3,900 tests at once with unknown compile fallout and a target-wide scheduling shift. Rejected for blast radius; recorded in the report as future work alongside a language-mode migration.

No deterministic regression test — red/green is run-level.

The bug is a scheduling race: the suite passes 20/20 in isolation by construction of the bug, and even a pristine-main full baseline run did not crash during this investigation. The report documents that a green full run is necessary but not sufficient, and that the structural guarantee (construction cannot leave the main-actor closure) carries the proof.

No CHANGELOG entry for test-infrastructure work.

The changelog records user-facing app changes; the precedent-setting prior T-1541 PRs (#330, #331) added no entries either.

(inferred — not stated by the author.)
Competing-implementations path skipped despite three prior failed attempts.

The fix-bug workflow prefers competing agents for bugs with failed history, but each competitor would need a full-suite run to verify, and concurrent xcodebuild full runs wedge the runner on this machine (documented on the ticket). With the mechanism understood, a single one-file fix with serialized verification was the better trade.

Review findings

SeverityAreaFindingResolution
minorreport.md vs test codeReport claimed WebKit construction was 'routed through a helper' while the code inlined MainActor.run in each of four tests (raised independently by the reuse and quality agents).Added the suite-private withWebView helper, making the report accurate and deduplicating the scaffold.
minorSVGWebViewTests copy-pasteIdentical construction scaffold repeated across four tests.Consolidated into withWebView; the WKWebView now never leaves the main-actor closure.
minorhtmlGenerationIncludesSVGContentAfter the dead createWebView() removal the test exactly duplicated coordinatorInitializesWithEmptyLastSVG, under a name describing behaviour it never tested, with a stale 'mock context' comment.Deleted the test.
nitcreateWebViewReturnsWKWebViewAssertion 'webView is WKWebView' is statically always true given the return type — zero value for the cost of a live web view (pre-existing, flagged by two agents).Deleted; construction success is subsumed by the data-store test.
nitsuite doc comment lengthFive paragraphs is long for a suite comment.Kept as-is: both agents judged the mechanism and rule paragraphs load-bearing given the natural 'simplification' would reintroduce the crash.

Per-file diffs

Click to expand.

prismTests/SVGWebViewTests.swift Modified +87 / -52
diff --git a/prismTests/SVGWebViewTests.swift b/prismTests/SVGWebViewTests.swiftindex c6800e7..9f0d567 100644--- a/prismTests/SVGWebViewTests.swift+++ b/prismTests/SVGWebViewTests.swift@@ -21,15 +21,31 @@ import WebKit /// - 3.1: SVGWebView implements NSViewRepresentable on macOS /// - 3.2: SVGWebView implements UIViewRepresentable on iOS ///-/// `@MainActor` here is load-bearing, not decoration. `createWebView()` builds a-/// `WKWebView` on `WKWebsiteDataStore.nonPersistentDataStore`, and WebKit traps-/// (SIGTRAP inside `WebKit::allDataStores()`) when a data store is constructed off-/// the main thread. Swift Testing runs suites in parallel and off the main actor-/// unless they are isolated, so without this the suite crashed its own test host —-/// which takes every test still queued in the whole run down with it and reports-/// them as failures at 0.000 seconds. That cascade is what made local runs-/// unreadable (T-1541), and this suite was its largest single source. Every other-/// live-WebKit suite in this target is already `@MainActor` for the same reason.+/// WebKit's one-time init (`InitializeWebKit2`) and its data-store bookkeeping+/// RELEASE_ASSERT the main thread, so constructing a `WKWebView` anywhere else+/// SIGTRAPs the shared test host — which takes every test still queued in the+/// whole run down with it as 0.000-second "failures" (T-1541). This suite was+/// that cascade's last surviving source.+///+/// `@MainActor` on the suite is NOT sufficient to prevent that for a+/// *synchronous* test. A sync function has no hop-on-entry: its isolation is+/// realized only if the caller hops first, and the caller here is+/// swift-testing's generated `nonisolated(nonsending)` thunk — compiled in this+/// target under Swift 5 language mode with Approachable Concurrency and no+/// default actor isolation — which inherits the runner's cooperative-pool+/// executor. Under full-suite parallel load the hop doesn't happen, Swift 5+/// mode has no runtime enforcement to catch it, and the sync body runs WebKit+/// init off-main. That is why the `@MainActor` added in PR #330 only moved the+/// trap (`allDataStores()` → `runInitializationCode()`), and why the suite+/// passes in isolation but crashed full runs.+///+/// The rule that follows, for every test in this target: WebKit may only be+/// touched from an `async` test (actor-isolated async functions hop on entry as+/// ABI, independent of the caller), and this suite additionally wraps the+/// construction in `MainActor.run`, which is runtime-enforced regardless of+/// language mode or caller. Every other live-WebKit suite already touches+/// WebKit exclusively through async code — that asymmetry is how this suite+/// was the only one still crashing. @MainActor struct SVGWebViewTests { @@ -69,49 +85,51 @@ struct SVGWebViewTests {      // MARK: - WKWebView Configuration -    @Test("createWebView returns a WKWebView")-    func createWebViewReturnsWKWebView() {-        let view = SVGWebView(svg: "<svg></svg>")-        let webView = view.createWebView()-        #expect(webView is WKWebView)+    /// The single entry point for constructing a live web view in this suite.+    /// Every WebKit-touching test goes through here so the main-thread+    /// requirement is runtime-enforced in exactly one place (see the suite doc+    /// comment): the test is async (hop-on-entry) and the construction plus the+    /// caller's assertions run inside `MainActor.run`, so the `WKWebView` never+    /// leaves the main-actor closure.+    private func withWebView(_ body: @MainActor (WKWebView) -> Void) async {+        await MainActor.run {+            body(SVGWebView(svg: "<svg></svg>").createWebView())+        }     }      @Test("createWebView uses non-persistent data store")-    func createWebViewUsesNonPersistentDataStore() {-        let view = SVGWebView(svg: "<svg></svg>")-        let webView = view.createWebView()--        // Non-persistent data store should be used for security-        // We can verify by checking the configuration-        let config = webView.configuration-        #expect(config.websiteDataStore.isPersistent == false)+    func createWebViewUsesNonPersistentDataStore() async {+        await withWebView { webView in+            // Non-persistent data store should be used for security+            // We can verify by checking the configuration+            let config = webView.configuration+            #expect(config.websiteDataStore.isPersistent == false)+        }     }      // MARK: - Platform-Specific Configuration      #if os(iOS)     @Test("iOS: createWebView sets transparent background")-    func iOSCreateWebViewSetsTransparentBackground() {-        let view = SVGWebView(svg: "<svg></svg>")-        let webView = view.createWebView()--        #expect(webView.isOpaque == false)-        #expect(webView.backgroundColor == .clear)-        #expect(webView.scrollView.backgroundColor == .clear)-        #expect(webView.scrollView.isScrollEnabled == false)+    func iOSCreateWebViewSetsTransparentBackground() async {+        await withWebView { webView in+            #expect(webView.isOpaque == false)+            #expect(webView.backgroundColor == .clear)+            #expect(webView.scrollView.backgroundColor == .clear)+            #expect(webView.scrollView.isScrollEnabled == false)+        }     }     #endif      #if os(macOS)     @Test("macOS: createWebView sets transparent background via KVC")-    func macOSCreateWebViewSetsTransparentBackground() {-        let view = SVGWebView(svg: "<svg></svg>")-        let webView = view.createWebView()--        // On macOS, WKWebView background transparency is set via KVC-        // We verify the webView was created - the actual KVC setting cannot be easily verified-        // without using the same KVC approach-        #expect(webView.frame == .zero, "WebView should be created with zero frame initially")+    func macOSCreateWebViewSetsTransparentBackground() async {+        await withWebView { webView in+            // On macOS, WKWebView background transparency is set via KVC+            // We verify the webView was created - the actual KVC setting cannot be easily verified+            // without using the same KVC approach+            #expect(webView.frame == .zero, "WebView should be created with zero frame initially")+        }     }     #endif @@ -130,20 +148,6 @@ struct SVGWebViewTests {         #expect(coordinator.lastSVG == "")     } -    // MARK: - HTML Generation--    @Test("HTML generation includes SVG content")-    func htmlGenerationIncludesSVGContent() {-        let svg = "<svg><circle cx=\"50\" cy=\"50\" r=\"40\"/></svg>"-        let view = SVGWebView(svg: svg)-        let webView = view.createWebView()-        let coordinator = view.makeCoordinator()--        // Create a mock context to test updateWebView-        // We can verify the SVG is set in coordinator-        #expect(coordinator.lastSVG.isEmpty, "Coordinator should start with empty SVG")-    }-     // MARK: - Transform-Application Decision (decision 11, Req 7.1)      @Test("transform is not applied before the page has loaded")
specs/bugfixes/svgwebviewtests-offmain-crash/report.md Added +169 / -0
diff --git a/specs/bugfixes/svgwebviewtests-offmain-crash/report.md b/specs/bugfixes/svgwebviewtests-offmain-crash/report.mdnew file mode 100644index 0000000..a361f61--- /dev/null+++ b/specs/bugfixes/svgwebviewtests-offmain-crash/report.md@@ -0,0 +1,200 @@+# Bugfix Report: SVGWebViewTests Off-Main WebKit Init Crash++**Date:** 2026-07-28+**Status:** Fixed++## Description of the Issue++Transit ticket: T-1541 (remaining scope after PRs #330/#331).++In full `make test-quick` runs (~3,900 tests), the test host process dies with a+SIGTRAP inside WebKit initialization — `WebKit::runInitializationCode()` via+`std::__call_once` / `WebKit::InitializeWebKit2()` / `WKWebViewConfiguration.init()`+at `SVGWebView.swift:90`, invoked from `SVGWebViewTests`. Because the whole unit-test+target shares one host process, every test still queued behind the crash is reported+as a failure with no recorded duration (~1,900 cascade entries per run).++The suite passes 20/20 in isolation and in a 10-suite WebKit-heavy subset. Only the+full run reproduces the crash. The faulting thread in every crash report is+`com.apple.root.user-initiated-qos.cooperative` — never the main thread — despite the+suite being annotated `@MainActor` since PR #330.++**Reproduction steps:**+1. On main (17b3b1b), run `make test-quick` (no `-skip-testing` flag)+2. Wait for the run to reach `SVGWebViewTests` under full parallel load+3. Observe SIGTRAP in WebKit init and a cascade of never-ran "failures"; `Tools/check-test-results.sh` separates the genuine failures from the cascade++**Impact:** Suite-level verdicts from full local runs are unusable unless+`prismTests/SVGWebViewTests` is skipped. Measured cost before the T-1541 fixes: ~10–20+minutes of baseline-comparison work per change, on every change.++## Investigation Summary++- **Symptoms examined:** Crash reports in `~/Library/Logs/DiagnosticReports/prism-*.ips`+  (four survive locally; all four predate the #331 merge and belong to crashers #331+  already fixed — the SVGWebViewTests stacks are recorded on the Transit ticket from+  the 2026-07-27 15:22/16:02 reports). Ticket history across PRs #330/#331.+- **Code inspected:** `prismTests/SVGWebViewTests.swift`, `prism/Views/SVGWebView.swift`,+  `prism.xcodeproj/project.pbxproj` build settings, `Makefile` test targets, and the+  other live-WebKit suites (`WebViewPoolTests`, `SVGRendererTests`, `MermaidRendererTests`).+- **Hypotheses tested:**+  - *"`@MainActor` on the suite keeps tests on the main thread"* — refuted by the+    post-#330 crash reports: the faulting thread is still the cooperative pool.+  - *"All WebKit suites should crash equally"* — refuted by inspection: SVGWebViewTests+    is the **only** suite in the target that constructs `WKWebViewConfiguration` /+    `WKWebView` directly inside **synchronous** test bodies. Every other live-WebKit+    suite touches WebKit from `async` code.+  - *"The target isolation asymmetry is the trigger"* — consistent: the app target sets+    `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `prismTests` does not, and all targets+    build in Swift 5 language mode with `SWIFT_APPROACHABLE_CONCURRENCY = YES`.++## Discovered Root Cause++**Defect type:** Race condition / unenforced actor isolation (test-only).++A synchronous `@MainActor` function has no hop-on-entry: its isolation is realized+only if the **caller** hops to the main actor before the call. The callers of these+test bodies are swift-testing's macro-generated async thunks, compiled in the test+target — Swift 5 language mode, no default actor isolation, Approachable Concurrency+enabled. Those thunks are `nonisolated(nonsending)` (the `Builtin.ImplicitActor`+frames in the crash stacks) and run on the *caller's* executor — swift-testing's+runner on the cooperative pool. Under full-run parallel load the expected hop to the+main actor does not happen before the synchronous body executes, and Swift 5 language+mode has no runtime enforcement to catch the mis-hop. The body then calls+`SVGWebView.createWebView()` → `WKWebViewConfiguration.init()` off-main, and WebKit's+one-time init `RELEASE_ASSERT`s that it is on the main thread, killing the host.++`async` members of a `@MainActor` type are immune: the switch to the actor's executor+on entry is emitted in the callee itself and is part of the ABI, independent of the+caller's module or language mode. That is why every other live-WebKit suite — which+touches WebKit only from `async` tests or `async` renderer APIs — survives the same+full runs.++**Why it occurred:** PR #330 fixed the crash class by annotation (`@MainActor` on the+suite), which is a compile-time promise the surrounding build configuration cannot+keep for synchronous functions. It moved the trap (from+`WebsiteDataStore::createNonPersistent` to `runInitializationCode`) without changing+the mechanism.++**Contributing factors:**+- `SWIFT_VERSION = 5.0` everywhere: isolation mis-hops are not runtime-enforced.+- App target has `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, test target does not.+- `SWIFT_APPROACHABLE_CONCURRENCY = YES` makes the generated nonisolated async thunks+  caller-inheriting (`nonisolated(nonsending)`), so which executor a sync test body+  runs on depends on runner scheduling — which is load-dependent, matching the+  "only in full runs" signature.++## Resolution for the Issue++**Changes made:**+- `prismTests/SVGWebViewTests.swift` — every test that constructs a live+  `WKWebView` (`createWebViewUsesNonPersistentDataStore`,+  `iOSCreateWebViewSetsTransparentBackground`, `macOSCreateWebViewSetsTransparentBackground`)+  is now `async` and routes construction through one suite-private helper,+  `withWebView`, whose body runs inside `await MainActor.run { }`. An+  actor-isolated `async` function hops to its executor on entry as part of the+  ABI (independent of the caller's module or language mode), and `MainActor.run`+  additionally enforces the main-actor executor at runtime — so WebKit+  construction in this suite can no longer execute off the main thread under any+  scheduling. The web view never leaves the main-actor closure. Assertions are+  unchanged.+- `prismTests/SVGWebViewTests.swift` — two tests were removed during the+  pre-push review of this fix: `createWebViewReturnsWKWebView` asserted+  `webView is WKWebView`, which is statically always true given the return type+  (zero assertion value at the cost of a live web view; construction success is+  proven by the data-store test), and `htmlGenerationIncludesSVGContent` —+  after its dead `createWebView()` call was removed — was an exact functional+  duplicate of `coordinatorInitializesWithEmptyLastSVG` under a name describing+  something it never tested.+- `prismTests/SVGWebViewTests.swift` — the suite doc comment now records the+  actual mechanism (sync `@MainActor` has no hop-on-entry; the PR #330+  annotation could only move the trap) and states the rule: WebKit may only be+  touched from `async` tests in this target.++**Approach rationale:** After two annotation-level attempts failed, the fix had+to be one whose guarantee is enforced at runtime rather than promised at compile+time. `MainActor.run` submits the closure to the main-actor executor no matter+which thread the test body landed on, so the crash becomes impossible by+construction. The change is confined to the one test file; production code is+untouched (production always constructs these views from SwiftUI representable+callbacks on the main thread).++**Alternatives considered:**+- *Set `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` on `prismTests` (fix the+  target asymmetry)* — plausibly the "real" fix, but it changes the default+  isolation of ~3,900 tests at once: unknown compile fallout across the target+  and a scheduling shift for every suite. Too much blast radius for this bug;+  worth considering separately alongside a Swift 6 language-mode migration.+- *Delete the WebKit-touching tests / keep skipping the suite* — loses real+  coverage of the security-relevant configuration (non-persistent data store)+  for no benefit over the runtime-enforced hop.+- *`try #require(Thread.isMainThread)` guard before construction* — fails+  legibly instead of crashing, but leaves the tests unable to run their WebKit+  assertions whenever the mis-hop occurs (flaky failures instead of a fix).+  `MainActor.run` both fixes and protects.++## Regression Test++The defect is a scheduling race that only manifests under full-suite parallel load;+a deterministic small red test cannot exist (the suite passes 20/20 in isolation by+construction of the bug). Red/green is therefore run-level:++- **Red:** full `make test-quick` on main with SVGWebViewTests included crashes the+  host and cascades (baseline run recorded below).+- **Green:** the same full run after the fix completes with zero cascade entries.++Structural regression protection is added instead of a probabilistic test: WebKit+construction in the suite is routed through a helper that runtime-enforces the main+thread, so any future mis-hop becomes a clean, legible test failure instead of a+host crash.++**Run command:** `make test-quick` (full run, no skip flag)++## Affected Files++| File | Change |+|------|--------|+| `prismTests/SVGWebViewTests.swift` | WebKit-touching tests made `async` + `MainActor.run`; dead web-view construction removed from a coordinator-only test; suite doc comment rewritten with the real mechanism |++## Verification++**Automated:**+- [x] Reworked SVGWebViewTests pass in isolation (20/20 before the pre-push+      review's test consolidation; 18/18 after — re-verified)+- [x] Full `make test-quick` (SVGWebViewTests included) completes with zero cascade+      entries and zero failures: total=3935, passed=3896, failed=0, skipped=37+      (the two flaky long-timeout scroll tests from the baseline passed this run);+      no new crash report generated+- [x] `make lint` passes (0 violations in 505 files)++**Manual verification:**+- Baseline full run on pristine main (17b3b1b), SVGWebViewTests included: did NOT+  crash this time (total=3935, passed=3894, failed=2 — both long-timeout live-WebKit+  scroll tests unrelated to this crasher, `WebScrollNavigationTests.visibleBlockSuppressedDuringProgrammaticScroll`+  and `WebScrollPositionRetentionTests.initialLoadRestoreSurvivesAutoReport`).+  This confirms the crash is a probabilistic scheduling race, not a deterministic+  failure: a green full run is necessary but not sufficient evidence. The proof of+  the fix is structural — after it, WebKit construction in this suite can only+  execute on the main actor executor, so the crash becomes impossible by+  construction rather than unlikely by scheduling.++## Prevention++**Recommendations to avoid similar bugs:**+- In this test target's build configuration, never touch WebKit (or any API with a+  hard main-thread `RELEASE_ASSERT`) from a synchronous test body — `@MainActor` on+  the suite is not a runtime guarantee for sync functions. Use an `async` test and/or+  `await MainActor.run { }` around the construction.+- A test must never be able to trap (established on T-1541): route+  main-thread-required setup through helpers that fail legibly.+- Longer term: migrating the test target to Swift 6 language mode (or giving it+  `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`) would make mis-hops compile-time or+  runtime errors; tracked as an alternative, not done here (blast radius spans the+  whole target).++## Related++- Transit T-1541 (this ticket), T-1983/T-1984 (siblings from the same cascade work)+- PR #330 (`81c1636`) — cascade scaffolding fix, added the suite `@MainActor`+- PR #331 (`17b3b1b`) — fixed the other named crashers; left SVGWebViewTests open+- `Tools/check-test-results.sh` — cascade/genuine failure discriminator
docs/agent-notes/development-tooling.md Modified +1 / -1
diff --git a/docs/agent-notes/development-tooling.md b/docs/agent-notes/development-tooling.mdindex 88e7ab5..37464bf 100644--- a/docs/agent-notes/development-tooling.md+++ b/docs/agent-notes/development-tooling.md@@ -7,7 +7,7 @@  ## Test-suite gotchas found while clearing the T-1541/T-1983 backlog -- **`SVGWebViewTests` still crashes the host in a FULL run** (T-1541 remains open). The `@MainActor` added in 81c1636 did not fix it — it only moved the trap, from `WebKit::allDataStores()` to `WebKit::runInitializationCode()`. The faulting thread is still `com.apple.root.user-initiated-qos.cooperative` (never thread 0), so the isolation is not taking effect under full-run conditions. It does NOT reproduce in isolation (20/20 pass, and a `Thread.isMainThread` probe passes there), nor with a WebKit-heavy subset — only in the whole suite. Until it is fixed, get a legible run with `-skip-testing:prismTests/SVGWebViewTests`, which takes the suite from ~1000 passing to the full ~3900.+- **`SVGWebViewTests` no longer needs skipping** (fixed on the T-1541 bugfix branch; `specs/bugfixes/svgwebviewtests-offmain-crash/report.md`). Root cause of the full-run host crash: the suite was the only one constructing `WKWebView` in *synchronous* test bodies. A sync `@MainActor` function has no hop-on-entry — its isolation depends on the caller hopping, and swift-testing's `nonisolated(nonsending)` thunks (Swift 5 mode + Approachable Concurrency, no default isolation in the test target) run on the runner's cooperative pool under full-suite load. WebKit's init `RELEASE_ASSERT(main thread)` then killed the host. The `@MainActor` added in 81c1636 could only move the trap. The rule for this target: **touch WebKit only from `async` tests** (actor-isolated async functions hop on entry as ABI); the suite additionally wraps construction in `MainActor.run`, which is runtime-enforced. - **The app and test targets have different actor-isolation defaults.** `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` is set on the app target only; `prismTests` and `prismUITests` have no default. Production code is therefore implicitly main-actor while the same type called from a test is not, which silently turns "safe because everything is on the main actor" into a real race in tests (see `RecentFileEntry.relativeDateFormatter`). Do not assume a production type's isolation holds inside a test. - **`XCTExpectFailure` breaks `check-test-results.sh`'s arithmetic check.** An expected failure is counted in neither `passedTests` nor `failedTests`, so the script prints `WARN: passed+failed+skipped != total`. That warning is benign when the difference equals the number of `XCTExpectFailure`s (currently 2, T-1985 and T-1986); it does not gate the build. - **One `MockURLProtocol` used to be shared by five suites** across `URLDocumentLoaderTests`, `ImageLoaderTests`, and `SVGSourceLoaderTests`. `.serialized` only orders tests *within* a suite, so the suites overwrote each other's static handler and served each other's payloads. Handlers are now registered per scope (`MockURLScope`), carried as a request header set from the session configuration. If you add a networked suite, give it its own scope rather than a global handler.

Things to double-check

Race non-determinism.

A green full run cannot prove the crash gone — even pristine main ran green once during this investigation. Confidence rests on the structural argument: WebKit construction now only executes inside MainActor.run. If the suite ever crashes a full run again, the mechanism is new and worth a fresh crash-report read.

Unrelated flaky tests observed across the three full runs.

WebScrollNavigationTests.visibleBlockSuppressedDuringProgrammaticScroll (13.7s) and WebScrollPositionRetentionTests.initialLoadRestoreSurvivesAutoReport (30.5s) failed in the baseline run; RawSourceViewModelTests.rapidLoadContentLeavesConsistentState (9.7s) failed in the post-review run and passed on rerun at 0.03s. All are load-timing flakes in areas this branch does not touch; worth a ticket if they recur.

CI on the PR is red for a non-code reason.

All three GitHub checks fail in 2–3s with 'The job was not started because recent account payments have failed or your spending limit needs to be increased' — the known account-level Actions billing block. Local equivalents pass (SwiftLint 0 violations in 505 files; full suite green).