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.
@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.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.is WKWebView assertion and a mislabeled duplicate of an existing coordinator test.-skip-testing:prismTests/SVGWebViewTests workaround is obsolete once this merges; agent notes and memory updated.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.
c68d022 T-1541: Add investigation report for svgwebviewtests-offmain-crash c729b95 T-1541: Fix SVGWebViewTests off-main WebKit init crash in full runs 858e4c8 T-1541: Apply pre-push review findings to SVGWebViewTests 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.
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.
@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.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.
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.
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.#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.MainActor.run is technically redundant when the async hop works — accepted cost; the redundancy is the point.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.
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.
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.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.RawSourceViewModelTests timing flake surfaced during the three full runs; all unrelated, all pass on rerun, worth a ticket if they recur.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
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)
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)
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)
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
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').
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.
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.
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.)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.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | report.md vs test code | Report 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. |
| minor | SVGWebViewTests copy-paste | Identical construction scaffold repeated across four tests. | Consolidated into withWebView; the WKWebView now never leaves the main-actor closure. |
| minor | htmlGenerationIncludesSVGContent | After 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. |
| nit | createWebViewReturnsWKWebView | Assertion '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. |
| nit | suite doc comment length | Five 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. |
Click to expand.
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")
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
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.
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.
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.
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).