prism branch T-2219/bugfix-test-host-abort-cascade-v2 commits 3 files 57 touched lines +1398 / -74 production code 1 file

Pre-push review: T-2219 host-abort cascade (PR #405)

Second pre-push pass. The previous review returned Needs fixes on six items; all six were addressed in 97e4d1f9 and each was re-verified here. Four parallel agents (reuse, quality, efficiency, spec/docs) plus independent build, lint, guard and targeted test runs.

At a glance

  • One production file changed. PrismDocSchemeHandler.swift gains a private SchemeTaskSink; every yield/finish routes through it. Behaviour is unchanged on every non-cancelled path.
  • The real lever is the test-concurrency cap. @Suite(.liveWebKit) charges 516 tests across 46 files / 52 suites against a 32-permit AsyncSemaphore; measured peak WebKit helper processes 226 → 59, with reclamation restored.
  • The change is unusually honest about its own limits. The sink is documented as hardening, not a closed door, and the report carries an explicit “What is proven and what is not” section. That framing is accurate — I re-derived it: Task.isCancelled can only become true after continuation.onTermination has already made the continuation inert.
  • The static guard is the durability argument. check-webkit-test-isolation.py now enforces budget coverage using the same _reaches_webkit predicate as the synchronous-construction rule, and refuses the reverse direction (deleting the budget or gutting provideScope).
  • Two latent guard false positives (a suite split across files via extension; a multi-line @Suite( attribute) exist but fail LOUD, not silent, and neither shape is in the target today.
  • Documentation volume is the main cost. The same explanation appears in five places; CLAUDE.md and docs/agent-notes/development-tooling.md overlap nearly paragraph-for-paragraph, which the project's own “do not duplicate CLAUDE.md” rule warns against.

Verdict

Ready to push

No blocking or code-level defect was found. The production change (SchemeTaskSink) is correct and behaviour-preserving — I enumerated all four cases of finish(throwing:) and confirmed no error is swallowed outside a cancelled task, and rejectedRouteStillThrows pins that. The .liveWebKit trait's permit accounting is exactly-once with no nesting anywhere in the target, so no deadlock and no double-charge. Everything checkable passes on my machine: make lint (0 violations), make verify-make-guards, make verify-test-isolation (guard clean, 83 unit tests green), make build-macos (Build Succeeded), and targeted -only-testing: runs of the new and newly-annotated suites (30 total / 19 passed / 11 disabled, execution-confirmed by check-test-results.sh).

All six previously-flagged items are genuinely addressed: scan now calls the shared _reaches_webkit; WebViewPool and SVGRenderer are seeded with transitive grounding; the “closed by construction” claim is demoted to hardening in the code, both reports, CLAUDE.md and the agent note; both guard false negatives are fixed with fixtures an independent agent confirmed red against the pre-fix script; verify_budget_exists now asserts provideScope still calls semaphore.acquire; and the doc pointers plus a Verification section are in place.

One should-fix before merge, not blocking: the report's Verification bullet attributes the run's 3 failures to “known T-2235 flakes”, but T-2235 is scoped to the iOS Simulator destination and live-WebPage tests, while this was a macOS test-quick run and the three failures are in suites that touch no web page. I ran all three suites on the branch: 22/22 pass in isolation, and none of them carries .liveWebKit, so they are pre-existing full-run contention flakes and not caused by this change. The substance of the bullet (no cascade) holds; only the label and the unexplained 40-test gap need correcting.

Review findings

13 raised · 0 fixed · 13 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism's automated tests all run inside one shared program. When that program crashes half-way through, every test that had not run yet gets reported as a failure — so a run that really had two or three problems could report hundreds. That false report has bitten this project repeatedly.

An earlier fix (PR #380) found one cause and fixed it, but the crash came back twice. This change finds the second cause and removes the conditions it needs.

Why it matters

If your test results lie to you, you cannot use them to decide whether your code is safe to ship. Every hour spent chasing a “failure” that never ran is wasted, and — worse — a real failure hiding in that noise gets ignored.

Key concepts

  • The crash could not be recorded. A web-engine error was thrown across a boundary Swift refuses to unwind through, so the program died inside the error, before any handler could log it. The only evidence was a system crash report, and those get deleted after a few days — which is how this ticket twice lost the only proof it had.
  • The blamed test was almost always innocent. Whichever test happened to be running at that instant took the blame. Twice it was a text-encoding test that never touches the web engine at all.
  • Too many web pages at once. Measurements showed the dying program had opened about 206 live web pages simultaneously. That is not what killed it — clean runs peak in the same place — but it is the crowded condition the crash needs. A new limit caps it at 32, and the peak fell from 226 helper processes to 59.
  • A safety net that cannot be forgotten. A script now fails the build if someone writes a new web-page test without the cap, so the fix cannot quietly erode.

Architecture

Three independent pieces, only one of which touches shipping code.

  1. SchemeTaskSink (prism/Services/WebRendering/PrismDocSchemeHandler.swift). The prism-doc:// scheme handler produces responses from an unstructured Task into an .unbounded AsyncThrowingStream. Handing a WKURLSchemeTask anything after WebKit has stopped it raises NSInternalInconsistencyException. All four production points now go through one small struct that no-ops once the producing task is cancelled — failures included, because a failure delivered to a stopped task raises the same exception a response does.
  2. LiveWebKitTrait (prismTests/Support/LiveWebKitBudget.swift). A swift-testing TestTrait/SuiteTrait/TestScoping that takes one permit from a 32-limit AsyncSemaphore per test case. -parallel-testing-worker-count 1 bounds host processes, not swift-testing's in-process concurrency, so nothing previously capped how many live pages coexisted.
  3. The static guard (Tools/check-webkit-test-isolation.py). A second rule: a @Test that can reach a WebKit constructor must be covered by .liveWebKit, on itself or on an enclosing suite. Both rules now call one predicate, _reaches_webkit.

Patterns worth borrowing

  • Source-contract testing where a behavioural test would be the outage. Reproducing this race aborts the process running it, so PrismDocSchemeTaskSinkContractTests asserts the structural property instead: no continuation.yield(/finish( outside the sink, plus a companion assertion that the sink still reaches the continuation so the rule cannot be satisfied by emptying it.
  • Guards that refuse both directions. verify_seeds keeps the production seed list honest; verify_test_harnesses discovers unlisted cross-file harnesses; verify_budget_exists refuses a deleted budget or a provideScope with its acquire removed.
  • Transitive grounding. SVGRenderer builds no WKWebView — it builds a WebViewPool, which does. verify_seeds resolves that as a fixpoint over the seed list and reports a cycle that grounds nothing, so two dead entries cannot keep each other alive.

Trade-offs taken

  • Reachability is seeded, not followed. PRODUCTION_WEBKIT_TYPES is the boundary of both rules, and a missing entry is a silent false negative nothing can discover. The change states this in four places rather than pretending otherwise — and it already cost a pass: WebViewPool and SVGRenderer were missing.
  • .unbounded stays. A bounded buffering policy was considered and rejected with reasons: AsyncStream has no back-pressure at any policy, so .bufferingNewest/.bufferingOldest would drop a .response or a .data and serve a truncated document — deterministic corruption traded for a marginal narrowing of a rare race.
  • Suite-level annotation over per-test. 145 of the 516 permit-taking tests never touch WebKit (mixed suites). That is 28% of queueing that buys nothing, accepted because per-test annotation is unmaintainable.

The mechanism, and why the guard could not see it

The captured stack (prism-2026-08-30-213219.ips) is abort ← swift::fatalError ← _swift_exceptionPersonality ← objc_exception_throw ← +[NSException raise:format:] ← WebKit ← runJobInEstablishedExecutorContext. The load-bearing frame is _swift_exceptionPersonality: the ObjC exception unwinds into a Swift frame, Swift's personality routine refuses, and the abort happens inside the throw. Consequences, each of which cost a pass on this ticket: nothing catches it; _objc_terminate is never reached so NSSetUncaughtExceptionHandler is unreachable (a recorder was built, armed, reproduced the abort and logged nothing — it was deleted); the exception text dies with the process; and the bundle blames whichever job was resumed at that instant.

PR #380's rule — a synchronous @MainActor body has no hop-on-entry — is a construction-site rule. This is a lifetime-path defect, so the guard passing was consistent with the cascade recurring.

What the sink actually buys, honestly

I re-derived this and the code's own claim is correct: the producing task is unstructured (Task { … } in reply(for:), no parent to propagate cancellation) and its only cancel() lives in continuation.onTermination. Termination is what makes the continuation inert, so by the time Task.isCancelled is true, yield and finish are already no-ops on their own. The guard can only ever agree with a stop; it cannot pre-empt one. Net behavioural change on every reachable path is therefore nil — the value is structural (a future upstream cancellation source is honoured, and the shape is pinned by a test), and the change says so in the type, both reports, CLAUDE.md and the agent note.

One clause in the report overstates what remains: it says the silent-failure path is “a real change in what gets delivered”. On the report's own model that path can only run once the continuation is inert, where finish(throwing:) delivers nothing anyway.

The condition, and why it is the interesting half

launchd recorded 230 com.apple.WebKit.* services started by the dead host with 226 alive simultaneously 11 ms before its last log line, only 4 ever reclaimed (~206 concurrent WebPages). Crucially, every clean run peaks in the same place (226, 228, 223, 231, 228, 227), so the working set is the condition the race needs rather than the trigger — which is exactly why the abort is load-dependent and never reproduces a suite in isolation. Capping it at 32 took the peak to 59 and restored reclamation. Nothing is skipped, excluded or reordered.

Edge cases I checked

  • finish(throwing:) case analysis. guard !Task.isCancelled, let error else { finish(); return } — not-cancelled/error → finish(throwing:); not-cancelled/nil → finish(); cancelled/either → finish(). No error is swallowed outside a cancelled task; rejectedRouteStillThrows pins the rejection path.
  • Permit accounting. defer is registered after acquire(), so a throwing acquire releases nothing (correct — AsyncSemaphore.cancelWaiter never handed out the slot). guard testCase != nil skips the suite-level scope, so a recursive suite trait cannot double-charge. Zero tests in the target are covered by two annotations, so no test acquires two permits and hold-and-wait is impossible. The Task { await release() } deferral costs a scheduling turn but cannot leak.
  • .serialized interaction. All 52 annotations are suite-level; .serialized only orders within a suite, so the combination is inert.
  • Guard boundary correctness. The new DECL_KEYWORD_RE check sits only in the depth ≥ 0 branch, so multi-line attribute walks are unaffected; string and comment content is blanked before the walk, so a keyword inside @Suite("…") cannot terminate it early.
  • The three failures in the recorded run. DocumentSessionScrollPersistenceTests, DocumentLayoutCoordinatorReloadTests, NotesManagerLoadRaceTests — none carries .liveWebKit, none touches a live page, and all 22 of their tests pass in isolation on the branch. Pre-existing full-run state contention, not a regression from the cap.

Important changes — detailed

PrismDocSchemeHandler: every production point routed through SchemeTaskSink

PrismDocSchemeHandler.swift

Why it matters. The only production-code change in the diff. It sits on the hot path that serves the document HTML, the CSS, and one request per image, so a mistake here is a user-visible serving bug, not a test-tooling bug.

What to look at. prism/Services/WebRendering/PrismDocSchemeHandler.swift:22-107 (the sink), :382-430 (reply), :435-560 (the three serve functions)

Takeaway. When a wrapper's whole job is to guard a call, put the case analysis in the review rather than trusting the shape. Here `guard !Task.isCancelled, let error else { finish() }` is correct across all four cases, but the reading order (`let error` second) makes it look like it might swallow a live error. `guard let error, !Task.isCancelled` would state the intent — 'an error, and someone left to tell' — without changing behaviour.
Rationale. A WKURLSchemeTask given a response, data, a finish, or a failure after WebKit stopped it raises NSInternalInconsistencyException, which aborts the process rather than throwing. Three of the four production points had no cancellation check at all, and the .rejected route finished with a throw, which becomes didFailWithError: on a possibly-stopped task.

LiveWebKitBudget: a 32-permit cap on concurrently-live WebKit pages

LiveWebKitBudget.swift

Why it matters. This is the piece that actually moved a measurement (226 -> 59 concurrent WebKit helper processes). It changes how 516 tests across 52 suites are scheduled, so a permit leak or a nesting deadlock would hang the whole pre-push gate with no diagnostic.

What to look at. prismTests/Support/LiveWebKitBudget.swift:44-96

Takeaway. `-parallel-testing-worker-count 1` bounds test HOST PROCESSES, not swift-testing's in-process concurrency, which is uncapped. If your slowest tests each hold an expensive external resource, that is unbounded resource growth with no flag to turn it off — a `TestScoping` trait over a semaphore is the lever, and `isRecursive` is what makes one suite annotation cover every test inside it.
Rationale. `.serialized` was the alternative and is far too strong: it would order the live suites one test at a time and it only applies WITHIN a suite anyway, which is precisely why the pile-up crosses suite boundaries. 32 was chosen to remove the pathological working set without serialising the run.

check-webkit-test-isolation.py: one reachability model, two rules

check-webkit-test-isolation.py

Why it matters. The guard is the entire durability argument for the fix — sixty test bodies were converted by hand and nothing but this script stops the sixty-first being written the old way. `scan` previously carried its own copy of the reachability chain, which is exactly the drift `_reaches_webkit` was extracted to prevent.

What to look at. Tools/check-webkit-test-isolation.py:1031-1236 (budget rules), :1145-1165 (_reaches_webkit), :1249-1262 (scan now delegates)

Takeaway. Two rules over one predicate beats two rules over two copies of a predicate, because the drift is silent in BOTH directions: a suite exempt from one rule and invisible to the other. Splitting out only the violation WORDING (`_reach_reason`) keeps the decision single-implementation while still producing a specific message.
Rationale. Explicitly stated in the commit message and the extracted function's own docstring.

The seed list is documented as the guard's boundary in four places

check-webkit-test-isolation.py

Why it matters. This is a false NEGATIVE the tooling cannot discover — the direction that reopens the host abort. It already cost a pass: `WebViewPool` and `SVGRenderer` were missing, so `SVGRendererTests.acceptSourceAtLimit` drove a real pooled WKWebView unchecked and uncapped.

What to look at. Tools/check-webkit-test-isolation.py:103-115 (docstring), :167-193 (the list), :1307-1338 (success output); plus CLAUDE.md and the agent note

Takeaway. When a check has a boundary that nothing inside the check can police, say so in the check's own success output, not only in a comment. A guard that prints an unqualified 'OK' invites the reader to believe the scope is total.
Rationale. Following Swift initialisers across the production target properly would need a real parser; the seed list exists to avoid exactly that. `verify_seeds` can only keep listed entries honest — it cannot find an absent one.

verify_seeds grows transitive grounding and cycle detection

check-webkit-test-isolation.py

Why it matters. Without it `SVGRenderer` is unlistable: it builds no WKWebView of its own, only a WebViewPool. Requiring a direct construction is what left `SVGRendererTests` uncapped in the first place.

What to look at. Tools/check-webkit-test-isolation.py:244-300

Takeaway. A staleness check that demands a direct relationship rejects the legitimate indirect case and pushes it off the list entirely — which is worse than a slightly weaker check. Making grounding a fixpoint fixes that, and reporting a cycle that grounds nothing stops two dead entries keeping each other alive.
Rationale. Stated in the function's rewritten docstring and the commit message.

Two guard false negatives fixed, both with red/green fixtures

check-webkit-test-isolation.py

Why it matters. Both silently GRANTED coverage that was never written, which is the failure mode that makes a guard worse than no guard. An independent agent reconstructed the pre-fix script and confirmed the new fixtures fail against it.

What to look at. Tools/check-webkit-test-isolation.py:943-953 (declaration boundary in _has_attribute), :1049-1077 (_type_declaration_lines keyed on the full owner path)

Takeaway. A backward attribute walk needs an explicit declaration boundary: `@Test(.liveWebKit) func previous() async { … }` on the line above starts with `@`, so without one the walk reads it as the NEXT declaration's attribute list. And keying a type map on the bare terminal name is not an identity — `Wrapper.Live` and a top-level `Live` collapse onto one entry.
Rationale. Both were found in the previous review of this PR and are documented in the commit message with the mutation each fixture was verified against.

Key decisions

Keep <code>.unbounded</code> rather than switching to a bounded buffering policy.

AsyncStream/AsyncThrowingStream have no back-pressure at any policy — yield never suspends. .bufferingNewest(n) and .bufferingOldest(n) DROP elements rather than slowing the producer, and this stream's elements are a .response followed by .data, so dropping one serves a truncated or headerless document. That trades a rare race for deterministic corruption of every large response. Real back-pressure would mean re-plumbing the handler onto an AsyncChannel-style rendezvous, which changes the serving path for every document, image and asset — out of scope for a crash fix.

A source contract, not a behavioural test, for the sink.

Reproducing the race means the losing side raises NSInternalInconsistencyException and aborts the process — taking the suite, the test host, and every still-queued test with it. A behavioural test would be the outage it is testing for. What is checkable is the structural property: every production point goes through the sink, plus a companion assertion that the sink still reaches the continuation so the rule cannot be satisfied by emptying it.

32 permits, not a small number and not <code>.serialized</code>.

The goal is removing the pathological working set (~206 pages, ~5 GB of WebContent), not serialising the suite. At 32 the live-WebKit tests still overlap enough that wall time is dominated by the slowest individual tests rather than by the queue. .serialized was rejected as far too strong and, decisively, because it only applies within a suite — which is the whole reason the pile-up crosses suite boundaries.

Suite-level annotation rather than per-test.

All 52 annotations sit on suites. This over-captures: 145 of the 516 permit-taking tests never touch WebKit, because several annotated suites are mixed (SVGRendererTests, WebViewPoolTests, both DocumentFlowCoordinator* suites). That is 28% of queueing that buys nothing, accepted because per-test annotation would be unmaintainable and would reintroduce exactly the “somebody forgot” failure mode the guard exists to prevent.

(inferred — not stated by the author.)
Release the permit on the next scheduling turn.

AsyncSemaphore.release() is actor-isolated and defer cannot await, so the trait uses defer { Task { await …release() } }. The file argues this is “soon enough for a cap whose purpose is to bound a working set rather than enforce an exact instantaneous count”. Note that WebViewPool.withWebView solves the identical problem with explicit do/catch and an awaited release on both paths — an AsyncSemaphore.withPermit helper would let both call sites share one answer.

Reachability into the production target is seeded, never followed.

PRODUCTION_WEBKIT_TYPES and PRODUCTION_WEBKIT_FACTORIES are the boundary of both rules. A production type that builds a page but is not named there is invisible to the synchronous-construction rule and the budget rule. Discovering such an omission is exactly the whole-target Swift parser the seed list exists to avoid, so the change documents the boundary in the script docstring, the script's success output, CLAUDE.md and the agent note instead of pretending it does not exist.

Delete the uncaught-exception recorder rather than ship it.

A recorder built on NSSetUncaughtExceptionHandler was written, armed from every live-WebKit entry point, reproduced the abort, and logged nothing — because _swift_exceptionPersonality aborts inside the throw, before _objc_terminate is ever reached. It was removed and the finding written into check-test-results.sh's diagnostic message instead. Recorded so nobody rebuilds it.

Review findings

SeverityAreaFindingResolution
majorspecs/bugfixes/webkit-scheme-task-stop-abort/report.md — Verification sectionThe bullet records "4712 tests executed, 4669 passed, no cascade. The failures were 3 known T-2235 flakes (plus their retries)". Three problems, in a ticket whose subject is fictional test counts. (a) 4712 - 4669 = 43, of which only 3 are accounted for; the other 40 are skipped tests plus expected failures and are never named. (b) T-2235 is explicitly scoped to the iOS Simulator destination and live-WebPage tests (I read the ticket); this was a macOS test-quick run and the three failing tests — DocumentSessionScrollPersistenceTests/fileSessionRestoresScrollPosition, DocumentLayoutCoordinatorReloadTests/reloadDocumentClearsBannerOnSuccess, NotesManagerLoadRaceTests/relocationSaveDoesNotDeleteNoteCreatedDuringLoad — touch no live page and none carries .liveWebKit. (c) make test-quick passes no -retry-tests-on-failure and prism.xctestplan sets no repetitions, so "plus their retries" describes a mechanism this target does not configure.Reported, not fixed (read-only review). I independently ran all three suites on the branch: 22/22 pass in isolation, execution-confirmed via check-test-results.sh, so they are pre-existing full-run state-contention flakes and NOT a regression from the cap — the substance of the bullet (no cascade) holds. Suggested edit: state the arithmetic as 4669 passed + 3 failed + 38 skipped + 2 expected failures, name the three tests, drop the retry claim, and either drop the T-2235 attribution or justify it.
minorspecs/bugfixes/webkit-scheme-task-stop-abort/report.md:138-140 — residual overclaimInside the paragraph that corrects the original overclaim, the report still says the sink buys that the producer "stops appending to an unbounded buffer" and that the silent-failure behaviour is "a real change in what gets delivered". Both contradict the model stated two sentences earlier: Task.isCancelled only becomes true after continuation.onTermination, at which point yield stores nothing and finish(throwing:) delivers nothing. The same sentence appears in the code comment at PrismDocSchemeHandler.swift:75-83.Reported, not fixed. The honest statement is that the sink's present value is structural (a future upstream cancellation source would be honoured, and the shape is pinned by a test), not measurable. Everything else in the demotion is accurate.
minorprismTests/WebRendering/PrismDocSchemeHandlerTests.swift:289-302 — the anti-gutting assertion is weaker than describedThe report says the companion assertion means the rule "cannot be satisfied by gutting it". It cannot be satisfied by EMPTYING the sink, but it can be satisfied by gutting the guard: delete `guard !Task.isCancelled else { return }` from SchemeTaskSink.yield and all four tests stay green, because line 302 only asserts the string "Task.isCancelled" appears somewhere in the sink region — finish still supplies it. Separately, the test matches raw source with no comment stripping, unlike the Python guard whose docstring specifically calls that trap out, so all three assertions are satisfiable by comment text and a future doc comment containing `continuation.yield(` would fail productionOnlyThroughTheSink spuriously.Reported, not fixed. Cheap remedy: assert per-method (the yield body contains the guard) and strip `//` lines before matching, reusing ProductionSourceScan.stripComment which exists for exactly this reason.
minorTools/check-webkit-test-isolation.py:1046,1125-1132 — false positive on a suite split across filesTYPE_DECL_RE covers struct/class/enum/actor but not extension, and _type_declaration_lines is built per file. A suite declared in A.swift and extended with a WebKit-reaching @Test in `extension FooTests` in B.swift is reported uncovered (demonstrated with a fixture). The repo already splits suites this way in at least five files. It fails safe — loud, not silent — but the remedy the message names is impossible, because @Suite cannot go on an extension. The docstring claim that the returned paths "are the same ones parse_members puts in Member.owner, so a lookup is exact" is also false for extensions, which parse_members flattens to the bare terminal name.Reported, not fixed. Build `declared` across the whole target, or at minimum amend the message to offer @Test(.liveWebKit) as the remedy when the suite lives in another file, and correct the docstring.
minorTools/check-webkit-test-isolation.py:936 — false positive on a wrapped @Suite attributeIn the multi-line-attribute branch, `carries` requires stripped.startswith("@"). That held while the searched patterns were @Test/@MainActor, but .liveWebKit is a trait ARGUMENT and can legally sit on its own continuation line inside a wrapped @Suite(...). Demonstrated with a fixture: a @Suite spread over four lines with .liveWebKit on its own line is reported uncovered. Latent today — the longest current annotation is 98 chars against SwiftLint's 140 — but it fires the first time someone wraps one.Reported, not fixed. In the depth < 0 branch, search the whole stripped line rather than requiring the @ prefix; the depth bookkeeping has already established the line is inside an attribute.
minorprismTests/Support/LiveWebKitBudget.swift:67 — nested annotation would take two permitsLiveWebKitTrait.isRecursive is true, so a test inside `@Suite(.liveWebKit) struct Outer { @Suite(.liveWebKit) struct Inner { … } }` receives the trait twice, gets two scopes, and acquires two permits sequentially. Thirty-two such tests each holding one and awaiting a second would deadlock the run with no diagnostic. Nothing forbids it — neither the trait nor the guard — and the doc comment inviting suite-level annotation is exactly the kind of line that prompts a defensive belt-and-braces second annotation. Zero tests in the target are doubly covered today, so this is latent.Reported, not fixed. Make the trait re-entrant with a @TaskLocal `holdsPermit` flag on LiveWebKitBudget, skipping acquisition when already held.
minorTools/check-webkit-test-isolation.py — guard runtime up 82%Measured: origin/main 3.31 s user CPU, branch 6.05 s. The script now makes four full read passes over prismTests (304 files) and calls parse_members 912 times and _depth_deltas 1520 times, neither of which is memoised (strip_noncode is). verify_budget_coverage alone is 3.00 s and duplicates the per-file pipeline scan already runs. The guard is a prerequisite of test-quick, test and test-locales, and the companion unittest run adds ~7 s more.Reported, not fixed. Adding lru_cache to parse_members and _depth_deltas takes the script from 6.3 s to 4.3 s with identical output — two decorators. ~4% of the test phase either way, so genuinely optional.
minorTest-run wall time was never measuredThe change caps 516 tests at 32-way concurrency and the Verification section records executed counts and the 226 -> 59 process census, but no duration. The per-test durations in the existing result bundles are dominated by queue inflation under the old 206-way contention (the same tests report 0.001-0.08 s in a targeted run and up to 20 s in a full run), so they cannot be used to bound the effect either way. The run completed and reported 4712 executed tests, so any regression is not catastrophic.Reported, not fixed. A single `time make test-quick` on origin/main versus the branch would close it, and is the one number this change should have and does not.
minorDocumentation duplication across CLAUDE.md and docs/agent-notes/development-tooling.mdThe .ips stack, the 226-process census, the seed-list boundary and the AsyncStream back-pressure argument are each stated in both files, nearly paragraph-for-paragraph (+53 lines and +85 lines respectively), on top of a 295-line report, three CHANGELOG entries and a ~55-line docstring on a 15-line struct. The project's own global instruction is "Do NOT duplicate CLAUDE.md" in agent-notes.Reported, not fixed. The agent-note version is the more detailed of the two and is the natural home; CLAUDE.md could carry the rule and a pointer. Deliberately not treated as a defect — the content is accurate everywhere and this ticket has been re-derived three times, which is a fair argument for redundancy.
nitspecs/bugfixes/webkit-scheme-task-stop-abort/report.md — smaller inaccuracies(a) "SchemeTaskSink is now the only thing in PrismDocSchemeHandler that touches the continuation" is false — reply(for:) sets continuation.onTermination directly; the code comment gets this right ("the one place… allowed to PRODUCE a response"). (b) The Resolution section lists "reply(for:) wires continuation.onTermination to cancel the task" as part of the fix, but that line is unchanged from origin/main. (c) "48 test files" — 46 files actually carry the annotation. (d) Cascade counts "190, 234" in the report versus "189 … 233" in CHANGELOG.md and CLAUDE.md. (e) Files Changed omits CLAUDE.md, CHANGELOG.md, the agent note and the amended sibling report. (f) CHANGELOG.md line 16 says "the two T-2219 entries under Fixed" — there are three.Reported, not fixed. All cosmetic, but (a) and (b) slightly inflate the change relative to what the diff does.
nitTools/check-webkit-test-isolation.py:1046 and 372 — TYPE_DECL_RE duplicates TYPE_REThe two patterns are character-identical apart from `protocol` being dropped from the new one; both already carry ^\s*{MODIFIERS}. The new regex's comment ("the previous pattern was anchored hard at column 0") describes an earlier draft within this branch, not TYPE_RE, so it does not justify a second copy. Separately, _type_declaration_lines re-implements the type-nesting walk that parse_members already performs and whose declaration indices it discards.Reported, not fixed. Reuse TYPE_RE, and have parse_members also return {owner path: declaration line} so the second walk disappears.
nitprismTests/WebRendering/PrismDocSchemeHandlerTests.swift:249-268,291-292handlerSource() hard-codes three deletingLastPathComponent() hops instead of using ProductionSourceScan.productionSourceRoot(sentinel:), which the sibling chokepoint suites use for exactly this. And the two source tests locate the closing MARK differently — one searches within afterSink, the other searches the whole file — so if the sink ever moves below "// MARK: - Routing model" the second forms a reversed Range and traps, which in the shared test host is the cascade this PR exists to stop.Reported, not fixed. Use the same afterSink search in both and hoist the MARK literal beside sinkType.
nitTools/Tests/test_webkit_test_isolation.py wiringInitially read as unwired, but it IS wired: Makefile:479 runs `python3 -m unittest Tools.Tests.test_webkit_test_isolation` as the second command of verify-test-isolation, which is a prerequisite of test-quick, test and test-locales. 83/83 pass. No action needed — recorded because the first pass over the Makefile missed it.Not a finding after verification.

Per-file diffs

Click to expand.

prism/Services/WebRendering/PrismDocSchemeHandler.swift Modified +112 / -18
diff --git a/prism/Services/WebRendering/PrismDocSchemeHandler.swift b/prism/Services/WebRendering/PrismDocSchemeHandler.swiftindex d2ca1ea2..4629c69d 100644--- a/prism/Services/WebRendering/PrismDocSchemeHandler.swift+++ b/prism/Services/WebRendering/PrismDocSchemeHandler.swift@@ -19,6 +19,92 @@ import Foundation import OSLog import WebKit +// MARK: - Stopped-task sink++/// The one place this handler is allowed to produce a response, and the reason it+/// exists is a process abort, not tidiness.+///+/// A `WKURLSchemeTask` raises `NSInternalInconsistencyException` — via+/// `+[NSException raise:format:]`, from WebKit — if it is given a response, data,+/// a finish, or a failure AFTER it has been stopped. In an app that is a crash; in+/// the shared unit-test host it is a catastrophe, because the abort takes every+/// still-queued test with it and they are all reported failed without having run+/// (T-2219: 283, 322, 190 and 234 fictional failures on separate runs).+///+/// The abort is not catchable and leaves almost nothing behind. The exception+/// unwinds into a Swift frame, so `_swift_exceptionPersonality` calls+/// `swift::fatalError` and aborts INSIDE the throw — before+/// `NSSetUncaughtExceptionHandler` or any `catch` can see it. The measured stack+/// (`prism-2026-08-30-213219.ips`) is exactly:+/// `abort <- swift::fatalError <- _swift_exceptionPersonality <-+/// objc_exception_throw <- +[NSException raise:format:] <- WebKit <-+/// swift::runJobInEstablishedExecutorContext` — a WebKit API raising on a Swift+/// concurrency job on the main thread.+///+/// Why this handler can reach that state at all: `reply(for:)` produces from a+/// `Task` into an `AsyncThrowingStream` whose buffering policy is `.unbounded`,+/// so a yield does NOT wait for the consumer. Production and consumption are+/// therefore independent, and WebKit stopping the task (navigation away, a+/// superseded load, the page being torn down — all routine, and all far more+/// frequent in a test host that builds and drops hundreds of pages) races every+/// yield the producer has not made yet. `serveDocument` and `serveAsset` had no+/// cancellation check at all, and the `.rejected` route finished with a THROW,+/// which becomes `didFailWithError:` on a task that may already be stopped.+///+/// So every production point goes through here, and every one of them stops+/// producing once the producing task is cancelled.+///+/// **This is hardening, not a closed door. Do not read it as one.** Two limits,+/// and the second is the important one:+///+/// 1. The `Task.isCancelled` guard and the `continuation` call after it are two+///    statements with no atomicity between them, so a stop landing in that gap is+///    not caught.+/// 2. More fundamentally, the cancel signal is DOWNSTREAM of the thing it is+///    meant to pre-empt. The producing task is unstructured (`Task { … }` in+///    `reply(for:)`, no parent to propagate cancellation), and the only+///    `cancel()` is in `continuation.onTermination`. Termination is what makes+///    the continuation inert, so by the time `Task.isCancelled` is true, `yield`+///    and `finish` are already no-ops on their own. The guard therefore cannot+///    win a race the continuation had not already stopped.+///+/// What it does buy is worth having and is only that: once the consumer is gone,+/// the producer stops doing work and stops appending to an unbounded buffer, so+/// there is less sitting in that buffer for WebKit's adapter to drain into a task+/// it has stopped — and a failure after a stop is reported as a plain finish+/// rather than an error, because there is no longer anybody to tell. The+/// remaining window lives inside WebKit's own consumer and nothing in the+/// `URLSchemeHandler` API can reach it: WebKit offers no way to ask whether a+/// task is still live, only the termination callback.+///+/// A bounded buffering policy is NOT the missing piece — see+/// `specs/bugfixes/webkit-scheme-task-stop-abort/report.md`. `AsyncStream` has no+/// back-pressure at any policy: `.bufferingNewest`/`.bufferingOldest` do not+/// suspend the producer, they DROP elements, and dropping a `.response` or a+/// `.data` from this stream serves a truncated document. That trades a rare race+/// for deterministic corruption.+private struct SchemeTaskSink {+    let continuation: AsyncThrowingStream<URLSchemeTaskResult, any Error>.Continuation++    /// Yields one result, unless the producing task has already been cancelled.+    func yield(_ result: URLSchemeTaskResult) {+        guard !Task.isCancelled else { return }+        continuation.yield(result)+    }++    /// Ends the stream. After a stop this finishes SILENTLY even when the caller+    /// wanted to report an error: a failure delivered to a stopped task is the+    /// same exception as a response delivered to one, and there is no longer+    /// anybody to tell.+    func finish(throwing error: (any Error)? = nil) {+        guard !Task.isCancelled, let error else {+            continuation.finish()+            return+        }+        continuation.finish(throwing: error)+    }+}+ // MARK: - Routing model  /// A decoded prism-doc:// request route.@@ -296,19 +382,20 @@ struct PrismDocSchemeHandler: URLSchemeHandler {         let onImageAccessNeeded = self.onImageAccessNeeded          return AsyncThrowingStream { continuation in+            let sink = SchemeTaskSink(continuation: continuation)             let task = Task {                 guard let url = request.url else {-                    continuation.finish(throwing: URLError(.badURL))+                    sink.finish(throwing: URLError(.badURL))                     return                 }                 do {                     switch route {                     case .document:                         try Self.serveDocument(-                            url: url, htmlProvider: documentHTMLProvider, into: continuation+                            url: url, htmlProvider: documentHTMLProvider, into: sink                         )                     case .asset(let name):-                        try Self.serveAsset(name: name, url: url, into: continuation)+                        try Self.serveAsset(name: name, url: url, into: sink)                     case .image(let src):                         try await Self.serveImage(                             src: src, url: url,@@ -319,21 +406,26 @@ struct PrismDocSchemeHandler: URLSchemeHandler {                                 directoryAccessManager: directoryAccessManager,                                 onImageAccessNeeded: onImageAccessNeeded                             ),-                            into: continuation+                            into: sink                         )                     case .rejected(let reason):                         Self.logger.error("Request rejected (category: \(String(describing: reason)))")-                        continuation.finish(throwing: URLError(.noPermissionsToReadFile))+                        sink.finish(throwing: URLError(.noPermissionsToReadFile))                     }                 } catch is CancellationError {                     // The page cancelled the task (navigation away / stop). Finish                     // silently — no further yields after cancellation (stop never                     // calls back).-                    continuation.finish()+                    sink.finish()                 } catch {-                    continuation.finish(throwing: error)+                    sink.finish(throwing: error)                 }             }+            // Stops the producer doing further work once the consumer is gone.+            // It is NOT a pre-emptive stop signal for the sink: termination has+            // already made the continuation inert by the time this runs, so the+            // sink's `Task.isCancelled` guards can only ever agree with it. See+            // `SchemeTaskSink` for what that does and does not buy.             continuation.onTermination = { _ in task.cancel() }         }     }@@ -343,21 +435,21 @@ struct PrismDocSchemeHandler: URLSchemeHandler {     private static func serveDocument(         url: URL,         htmlProvider: (@Sendable () -> String)?,-        into continuation: AsyncThrowingStream<URLSchemeTaskResult, any Error>.Continuation+        into sink: SchemeTaskSink     ) throws {         guard let html = htmlProvider?() else {             throw URLError(.resourceUnavailable)         }         let data = Data(html.utf8)-        continuation.yield(.response(documentResponse(for: url, contentLength: data.count)))-        continuation.yield(.data(data))-        continuation.finish()+        sink.yield(.response(documentResponse(for: url, contentLength: data.count)))+        sink.yield(.data(data))+        sink.finish()     }      private static func serveAsset(         name: String,         url: URL,-        into continuation: AsyncThrowingStream<URLSchemeTaskResult, any Error>.Continuation+        into sink: SchemeTaskSink     ) throws {         // Allowlist already enforced by routing; resolve by name+extension only,         // never by a path from the request.@@ -380,9 +472,9 @@ struct PrismDocSchemeHandler: URLSchemeHandler {                 "Content-Security-Policy": contentSecurityPolicy,             ]         ) ?? URLResponse(url: url, mimeType: nil, expectedContentLength: data.count, textEncodingName: nil)-        continuation.yield(.response(response))-        continuation.yield(.data(data))-        continuation.finish()+        sink.yield(.response(response))+        sink.yield(.data(data))+        sink.finish()     }      /// The per-request context `serveImage` needs beyond the request itself. Bundled so@@ -400,7 +492,7 @@ struct PrismDocSchemeHandler: URLSchemeHandler {         src: String,         url: URL,         context: ImageServeContext,-        into continuation: AsyncThrowingStream<URLSchemeTaskResult, any Error>.Continuation+        into sink: SchemeTaskSink     ) async throws {         let resolved = ImagePathResolver.resolve(             source: src, baseURL: context.imageBaseURL, sourceType: context.sourceType@@ -463,9 +555,9 @@ struct PrismDocSchemeHandler: URLSchemeHandler {             ]         ) ?? URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil)         try Task.checkCancellation()-        continuation.yield(.response(response))-        continuation.yield(.data(data))-        continuation.finish()+        sink.yield(.response(response))+        sink.yield(.data(data))+        sink.finish()     }      /// Loads the resolved image, routing SVG sources through the sanitizer so a
prismTests/Support/LiveWebKitBudget.swift Added +101 / -0
diff --git a/prismTests/Support/LiveWebKitBudget.swift b/prismTests/Support/LiveWebKitBudget.swiftnew file mode 100644index 00000000..74447989--- /dev/null+++ b/prismTests/Support/LiveWebKitBudget.swift@@ -0,0 +1,101 @@+//+//  LiveWebKitBudget.swift+//  prismTests+//+//  Bounds how many tests may hold a live WebKit page AT THE SAME TIME (T-2219).+//+//  Why this exists, measured rather than reasoned: on the run that reproduced the+//  host abort, `launchd` recorded 230 `com.apple.WebKit.*` XPC services started by+//  the one test host and **226 of them alive simultaneously** at 16:15:18.600 —+//  the host logged its last line at 16:15:18.611. The breakdown was ~206+//  `WebContent` (one per live `WebPage`), 20 `Networking`, 20 `GPU`. Every clean+//  run measured the same peak (226, 228, 223, 231, 228, 227), so the count is not+//  itself the trigger; it is the CONDITION the trigger needs, which is why the+//  abort is load-dependent, never reproduces a suite in isolation, and gets+//  attributed to whatever unrelated test happened to be in flight.+//+//  Where 206 concurrent pages comes from: `-parallel-testing-worker-count 1`+//  bounds test HOST PROCESSES, not swift-testing's in-process concurrency, and+//  swift-testing runs tests in parallel with no cap. The live-WebKit tests are+//  also the slowest in the target (they await real navigations, 0.2–25 s), so+//  they are precisely the ones that pile up: every test suspended on a navigation+//  is still holding its page, its WebContent process, and that process's share of+//  the host's descriptors, ports, and memory.+//+//  No test needs that. A cap restores a normal working set without skipping,+//  excluding, or reordering anything: the same tests run, the same assertions+//  fire, the executed-test count is unchanged. `.serialized` is the alternative+//  and is far too strong — it would order the live suites one test at a time and+//  it only applies WITHIN a suite anyway, which is the whole reason the pile-up+//  crosses suite boundaries.+//+//  Coverage is enforced statically: `Tools/check-webkit-test-isolation.py` fails+//  the build when a `@Test` that can reach a WebKit constructor is not covered by+//  `.liveWebKit`. A budget nobody remembers to apply is not a budget.+//++import Foundation+import Testing+@testable import prism++/// The process-wide cap on concurrently-live WebKit pages in the test host.+enum LiveWebKitBudget {++    /// How many tests may hold a live page at once.+    ///+    /// 32 rather than a small number on purpose. The point is to remove the+    /// pathological working set (206 pages, ~5 GB of WebContent), not to+    /// serialise the suite: at 32 the live-WebKit tests still overlap enough that+    /// the run's wall time is dominated by its slowest individual tests rather+    /// than by the queue. Lower it only with a measurement; raising it back+    /// towards the uncapped peak reinstates the condition the abort needs.+    static let limit = 32++    static let semaphore = AsyncSemaphore(limit: limit)+}++/// Holds one live-page permit for the duration of a test.+///+/// Applied as `@Suite(.liveWebKit)` (or `@Test(.liveWebKit)`), it is recursive, so+/// annotating the suite covers every test and nested suite inside it.+struct LiveWebKitTrait: TestTrait, SuiteTrait, TestScoping {++    /// Recursive so one annotation on the suite covers all of its tests. A+    /// non-recursive suite trait would wrap the SUITE, i.e. hold a single permit+    /// for the whole suite's run, which both under-counts (one permit for many+    /// concurrent tests) and deadlocks nothing but bounds nothing either.+    var isRecursive: Bool { true }++    func provideScope(+        for test: Test,+        testCase: Test.Case?,+        // `@concurrent` is load-bearing, not decoration: `prismTests` builds with+        // SWIFT_APPROACHABLE_CONCURRENCY, which makes an unannotated async closure+        // parameter `nonisolated(nonsending)` — a DIFFERENT type from the one the+        // `Testing` module (built without that flag) declares, so the conformance+        // simply fails to compile without it.+        performing function: @concurrent @Sendable () async throws -> Void+    ) async throws {+        // A suite-level scope (`testCase == nil`) wraps the suite, not a test:+        // taking a permit there would hold one for the suite's entire run on top+        // of the permits its tests take. Only test cases are charged.+        guard testCase != nil else {+            try await function()+            return+        }+        try await LiveWebKitBudget.semaphore.acquire()+        defer {+            // `release()` is actor-isolated and `defer` cannot await. The permit+            // is handed back on the next scheduling turn, which is soon enough+            // for a cap whose purpose is to bound a working set rather than to+            // enforce an exact instantaneous count.+            Task { await LiveWebKitBudget.semaphore.release() }+        }+        try await function()+    }+}++extension Trait where Self == LiveWebKitTrait {+    /// Bounds concurrently-live WebKit pages in the shared test host (T-2219).+    static var liveWebKit: Self { LiveWebKitTrait() }+}
Tools/check-webkit-test-isolation.py Modified +309 / -36
diff --git a/Tools/check-webkit-test-isolation.py b/Tools/check-webkit-test-isolation.pyindex 5f83a02d..7a11a9f3 100755--- a/Tools/check-webkit-test-isolation.py+++ b/Tools/check-webkit-test-isolation.py@@ -100,6 +100,19 @@ Comments and string literals are blanked before any of this, so a constructor spelling that a source-contract test merely *quotes* is not a construction, and a brace inside an HTML or JS fixture cannot desynchronise the walker. +Where the model stops+---------------------+Reachability into the PRODUCTION target is not followed; it is seeded.+`PRODUCTION_WEBKIT_TYPES` and `PRODUCTION_WEBKIT_FACTORIES` are the boundary, and+a production type that builds a page but is not named there is invisible to BOTH+rules — a test that drives it scans clean, and its suite is never asked to carry+`.liveWebKit`. That is a false NEGATIVE, and nothing here can discover it:+discovering it is exactly the whole-target Swift parser the seed list exists to+avoid. `verify_seeds` only keeps the listed entries honest. The cost is real and+was paid on T-2219's review — `WebViewPool` and `SVGRenderer` were missing, so+`SVGRendererTests` drove a live pooled `WKWebView` unchecked and uncapped. When a+production type starts building WebKit, it goes on that list in the same change.+ Usage: Tools/check-webkit-test-isolation.py [repo-root] Exit 0 when clean, 1 on any violation (or if a seed list has gone stale). """@@ -151,11 +164,29 @@ WEBKIT_CONSTRUCTORS = [ # would need a real parser; `verify_seeds` below fails the run if one of these # stops being a WebKit constructor (renamed, or the construction moved), so the # list cannot silently go blind.+#+# THIS LIST IS THE BOUNDARY OF THE GUARD. Reachability into the production target+# is transitive through the names written here and nowhere else: a production type+# that builds a page but is not listed is invisible to BOTH rules — the+# synchronous-construction rule and the `.liveWebKit` budget rule — and every test+# that drives it scans clean. That is a false NEGATIVE, the direction that reopens+# the host abort, and nothing here can discover it; discovering it is exactly the+# whole-target Swift parser this list exists to avoid. `verify_seeds` only keeps+# the entries that ARE listed honest. So when a production type starts building+# WebKit, it is added here in the same change. PRODUCTION_WEBKIT_TYPES = {     # symbol: production file that must still construct WebKit     "MermaidRenderer": "prism/Services/MermaidRenderer.swift",     "WebDocumentController": "prism/ViewModels/WebDocumentController.swift",     "FootnotePopoverWebPage": "prism/ViewModels/FootnotePopoverWebPage.swift",+    # The offscreen-rendering half of the app, and the omission T-2219's review+    # found: `SVGRendererTests.acceptSourceAtLimit` is enabled and drives a real+    # pooled `WKWebView`, and the whole suite was uncapped because neither type+    # was listed. `WebViewPool` builds the views; `SVGRenderer` builds a pool in+    # its own initialiser, so it is grounded through `WebViewPool` rather than+    # directly (see `verify_seeds`).+    "WebViewPool": "prism/Services/WebViewPool.swift",+    "SVGRenderer": "prism/Services/SVGRenderer.swift", }  # Production factories that hand back one of the types above.@@ -213,9 +244,18 @@ def seed_pattern() -> re.Pattern[str]:   def verify_seeds(root: Path) -> list[str]:-    """Each named production type must still construct WebKit, or the list is stale.--    Granularity worth knowing: this checks the FILE still contains a WebKit+    """Each named production type must still reach WebKit, or the list is stale.++    "Reach" rather than "construct directly", because one listed type is grounded+    through another: `SVGRenderer` builds no `WKWebView` of its own, it builds a+    `WebViewPool`, which does. Requiring a direct construction would make that+    seed unlistable, and leaving it off is what left `SVGRendererTests` uncapped.+    So grounding is a fixpoint over the list: a seed is grounded when its file+    constructs WebKit directly, or constructs an already-grounded seed. A cycle of+    seeds that only construct each other grounds nothing and is reported, which is+    the point — otherwise two dead entries would keep each other alive.++    Granularity worth knowing: this checks the FILE still contains the     construction, not that the named type's initialiser is what performs it. A     type that stopped building a page while some other declaration in the same     file kept one keeps this green. It catches the renames and relocations that@@ -223,6 +263,7 @@ def verify_seeds(root: Path) -> list[str]:     """     ctor = re.compile("|".join(WEBKIT_CONSTRUCTORS))     problems = []+    texts: dict[str, str] = {}     for symbol, rel in PRODUCTION_WEBKIT_TYPES.items():         path = root / rel         if not path.exists():@@ -231,10 +272,28 @@ def verify_seeds(root: Path) -> list[str]:                 f"constructs WebKit. Update PRODUCTION_WEBKIT_TYPES."             )             continue-        if not ctor.search(strip_noncode(path.read_text(encoding="utf-8"))):+        texts[symbol] = strip_noncode(path.read_text(encoding="utf-8"))++    grounded = {symbol for symbol, text in texts.items() if ctor.search(text)}+    for _ in range(len(texts) + 1):+        changed = False+        for symbol, text in texts.items():+            if symbol in grounded:+                continue+            for other in sorted(grounded):+                if re.search("|".join(construction_patterns(other)), text):+                    grounded.add(symbol)+                    changed = True+                    break+        if not changed:+            break++    for symbol, rel in PRODUCTION_WEBKIT_TYPES.items():+        if symbol in texts and symbol not in grounded:             problems.append(-                f"{rel} no longer constructs WebKit directly, so listing {symbol} "-                f"as a WebKit constructor is stale. Update PRODUCTION_WEBKIT_TYPES."+                f"{rel} no longer constructs WebKit — directly or through another "+                f"listed type — so listing {symbol} as a WebKit constructor is "+                f"stale. Update PRODUCTION_WEBKIT_TYPES."             )     return problems @@ -884,6 +943,14 @@ def _has_attribute(         if stripped == "":             back -= 1             continue+        # `@Test(.liveWebKit) func previous() async { … }` on the line above is a+        # COMPLETE other declaration whose attributes isolate IT, not this one.+        # It starts with `@`, so without a declaration boundary the walk reads it+        # as this declaration's attribute list and the trait leaks onto the next+        # test — silently granting coverage that was never written (T-2219+        # review). Checked before `carries` so the boundary wins.+        if DECL_KEYWORD_RE.search(stripped):+            return False         if carries:             return True         # A closing bracket continues an attribute; a closing BRACE is the end of@@ -961,6 +1028,213 @@ def taint(     return calls, reads, owners  +# MARK: - Live-WebKit concurrency budget (T-2219)++# The trait that charges a test against `LiveWebKitBudget`, spelled as it appears+# in an attribute. A `@Test` that can reach a WebKit constructor must be covered+# by it, on itself or on its suite.+BUDGET_TRAIT = ".liveWebKit"+BUDGET_TRAIT_RE = re.compile(r"\.liveWebKit\b")++# Where the budget lives, so a failure message can point at it.+BUDGET_FILE = "prismTests/Support/LiveWebKitBudget.swift"++# Leading whitespace and the full modifier set on purpose: the previous pattern+# was anchored hard at column 0, so no NESTED type was ever found and every test+# in one fell back to a same-named type elsewhere in the file (see+# `_type_declaration_lines`).+TYPE_DECL_RE = re.compile(rf"^\s*{MODIFIERS}(?:struct|class|enum|actor)\s+(\w+)")+++def _type_declaration_lines(lines: list[str]) -> dict[str, int]:+    """{full dotted type path: index of the line its declaration starts on}.++    Keyed on the FULL path rather than the bare terminal name, because the bare+    name is not an identity: `Wrapper.Live` and a top-level `Live` collapse onto+    one entry, and the trait check then reads the wrong attribute block —+    crediting `Wrapper.Live` with coverage that belongs to its namesake, or+    denying it coverage of its own (T-2219 review). The paths this returns are the+    same ones `parse_members` puts in `Member.owner`, so a lookup is exact.++    Brace depth carries the nesting: a type declared at depth d owns every line+    until depth returns to d.+    """+    deltas = _depth_deltas(lines)+    declared: dict[str, int] = {}+    stack: list[tuple[str, int]] = []+    depth = 0+    for index, line in enumerate(lines):+        while stack and depth <= stack[-1][1]:+            stack.pop()+        match = TYPE_DECL_RE.match(line)+        if match:+            prefix = ".".join(name for name, _ in stack)+            name = match.group(1)+            declared.setdefault(f"{prefix}.{name}" if prefix else name, index)+            stack.append((name, depth))+        depth += deltas[index]+    return declared+++def verify_budget_coverage(root: Path) -> list[str]:+    """Every test that can reach WebKit must be charged against the page budget.++    The host abort this guard exists for needs a CONDITION as well as a trigger:+    hundreds of live `WebPage`s at once (measured: 226 concurrent+    `com.apple.WebKit.*` services in the host at the instant it died, and the same+    peak on every clean run). `-parallel-testing-worker-count 1` does not bound+    that — it bounds test HOST PROCESSES, while swift-testing runs tests in+    parallel INSIDE one host with no cap, and the live-WebKit tests are the+    slowest in the target, so they are exactly the ones that pile up.++    `.liveWebKit` caps it. A cap that a new suite can be written without is not a+    cap, and new live-WebKit suites are added regularly — which is why this is+    checked rather than documented. The reachability model is the same one the+    synchronous-construction rule uses, so a suite cannot be covered by one and+    invisible to the other.+    """+    seeds = seed_pattern()+    problems: list[str] = []+    for path in sorted((root / TEST_ROOT).rglob("*.swift")):+        raw = path.read_text(encoding="utf-8")+        lines = strip_noncode(raw).split("\n")+        deltas = _depth_deltas(lines)+        members = parse_members(raw.split("\n"))+        calls, reads, owners = taint(members, seeds)++        # Owner path -> the line its declaration starts on, so its attributes can+        # be read. A file may declare several suites; only the ones that reach+        # WebKit are required to carry the trait.+        declared = _type_declaration_lines(lines)++        uncovered: dict[str, str] = {}+        for member in members:+            if not member.is_test:+                continue+            if not _reaches_webkit(member, seeds, calls, reads, owners):+                continue+            owner = member.owner or ""+            index = member.line - 1+            # Trait on the test itself covers it without a suite annotation.+            if 0 <= index < len(lines) and _has_attribute(+                lines, deltas, index, BUDGET_TRAIT_RE+            ):+                continue+            # `LiveWebKitTrait.isRecursive` is true, so an annotation on any+            # ENCLOSING suite covers this test. Walk the owner path outwards.+            path_parts = owner.split(".") if owner else []+            covered = False+            while path_parts:+                at = declared.get(".".join(path_parts))+                if at is not None and _has_attribute(lines, deltas, at, BUDGET_TRAIT_RE):+                    covered = True+                    break+                path_parts.pop()+            if covered:+                continue+            uncovered.setdefault(owner or "(file scope)", member.name)++        for owner, example in sorted(uncovered.items()):+            problems.append(+                f"{path.relative_to(root)}: {owner} reaches WebKit (e.g. {example}()) but is "+                f"not annotated `@Suite({BUDGET_TRAIT})`, so its live pages are uncapped."+            )+    return problems+++def _reaches_webkit(member, seeds, calls, reads, owners) -> bool:+    """Whether `member`'s body can reach a WebKit construction.++    Extracted from `scan` so the budget check and the isolation check share ONE+    reachability model. Two copies would drift, and the drift is silent in both+    directions: a suite exempt from one rule and invisible to the other.+    """+    if seeds.search(member.body):+        return True+    if member.owner in owners:+        return True+    if any(_mentions(member.body, name, True) for name in calls):+        return True+    if any(_mentions(member.body, name, False) for name in reads):+        return True+    return any(+        _mentions(member.body, owner.rsplit(".", 1)[-1], True)+        for owner in owners+        if owner.rsplit(".", 1)[-1]+    )+++def _reach_reason(member, seeds, calls, reads, owners) -> str:+    """Why `_reaches_webkit` said yes, phrased for the violation message.++    Kept beside the predicate and used only after it has answered yes, so the+    decision has exactly one implementation and this adds no second opinion about+    it. The final fallback is unreachable while that ordering holds; it is there+    so a future edit that breaks the ordering degrades to a vaguer message rather+    than to a wrong one.+    """+    if seeds.search(member.body):+        return "constructs WebKit directly"+    if member.owner in owners:+        return (+            f"belongs to {member.owner or 'file scope'}, which builds WebKit outside "+            f"any test body (a stored property, init, deinit or subscript), so every "+            f"test in it runs that code"+        )+    via = [f"{name}()" for name in sorted(calls) if _mentions(member.body, name, True)]+    via += [name for name in sorted(reads) if _mentions(member.body, name, False)]+    via += [+        f"{owner.rsplit('.', 1)[-1]}()"+        for owner in sorted(owners)+        if owner.rsplit(".", 1)[-1]+        and _mentions(member.body, owner.rsplit(".", 1)[-1], True)+    ]+    if not via:+        return "can reach WebKit"+    return "constructs WebKit via " + ", ".join(dict.fromkeys(via))+++def verify_budget_exists(root: Path) -> list[str]:+    """The budget file must still define the trait this check requires.++    Without this the rule can be satisfied by deleting the budget: every suite+    keeps its `@Suite(.liveWebKit)` annotation, the annotation compiles against+    nothing, and the check passes while the cap is gone. Same shape as+    `verify_seeds` — a guard that goes quiet exactly when its subject is removed+    is worse than no guard.+    """+    path = root / BUDGET_FILE+    if not path.exists():+        return [f"{BUDGET_FILE} is gone, so `{BUDGET_TRAIT}` caps nothing."]+    text = strip_noncode(path.read_text(encoding="utf-8"))+    problems = []+    if "static var liveWebKit" not in text:+        problems.append(f"{BUDGET_FILE} no longer defines the `{BUDGET_TRAIT}` trait.")+    if "AsyncSemaphore" not in text:+        problems.append(+            f"{BUDGET_FILE} no longer bounds concurrency with a semaphore, so "+            f"`{BUDGET_TRAIT}` is an annotation with no cap behind it."+        )+    # Declaring a semaphore is not taking a permit. `provideScope` is the only+    # thing the trait actually runs, so if the acquire is deleted from it the+    # budget still exists, every suite keeps its annotation, and nothing is+    # capped — the same "guard satisfied by gutting its subject" shape the+    # companion assertion in `PrismDocSchemeTaskSinkContractTests` covers on the+    # Swift side.+    scope = text.split("func provideScope", 1)+    if len(scope) < 2:+        problems.append(+            f"{BUDGET_FILE} no longer implements `provideScope`, so `{BUDGET_TRAIT}` "+            f"wraps nothing."+        )+    elif "semaphore.acquire" not in scope[1]:+        problems.append(+            f"{BUDGET_FILE}'s `provideScope` no longer calls `semaphore.acquire`, so "+            f"`{BUDGET_TRAIT}` takes no permit and caps nothing."+        )+    return problems++ def scan(root: Path) -> list[str]:     seeds = seed_pattern()     violations: list[str] = []@@ -987,26 +1261,13 @@ def scan(root: Path) -> list[str]:             # nobody remembered to annotate.             if not member.is_test or (member.is_async and member.is_main_actor):                 continue-            if seeds.search(member.body):-                reason = "constructs WebKit directly"-            elif member.owner in owners:-                reason = (-                    f"belongs to {member.owner or 'file scope'}, which builds WebKit outside "-                    f"any test body (a stored property, init, deinit or subscript), so every "-                    f"test in it runs that code"-                )-            else:-                via = [f"{name}()" for name in sorted(calls) if _mentions(member.body, name, True)]-                via += [name for name in sorted(reads) if _mentions(member.body, name, False)]-                via += [-                    f"{owner.rsplit('.', 1)[-1]}()"-                    for owner in sorted(owners)-                    if owner.rsplit(".", 1)[-1]-                    and _mentions(member.body, owner.rsplit(".", 1)[-1], True)-                ]-                if not via:-                    continue-                reason = "constructs WebKit via " + ", ".join(dict.fromkeys(via))+            # ONE reachability model, shared with the budget rule. `scan` used to+            # carry its own copy of this chain, which is exactly the drift+            # `_reaches_webkit` was extracted to prevent: a suite exempt from one+            # rule and invisible to the other. Only the EXPLANATION is built here.+            if not _reaches_webkit(member, seeds, calls, reads, owners):+                continue+            reason = _reach_reason(member, seeds, calls, reads, owners)             posture = (                 "is `async` but neither it nor its suite is @MainActor, so the "                 "hop it relies on is never emitted, and it"@@ -1021,7 +1282,7 @@ def scan(root: Path) -> list[str]:  def main(argv: list[str]) -> int:     root = Path(argv[1]).resolve() if len(argv) > 1 else Path(__file__).resolve().parent.parent-    stale = verify_seeds(root) + verify_test_harnesses(root)+    stale = verify_seeds(root) + verify_test_harnesses(root) + verify_budget_exists(root)     if stale:         print("FAIL [webkit-test-isolation]: the guard's seed lists are stale:", file=sys.stderr)         for problem in stale:@@ -1046,7 +1307,35 @@ def main(argv: list[str]) -> int:             file=sys.stderr,         )         return 1-    print("[webkit-test-isolation] OK: no synchronous test can construct WebKit.")+    uncapped = verify_budget_coverage(root)+    if uncapped:+        print(+            f"FAIL [webkit-test-isolation]: {len(uncapped)} suite(s) can hold a live "+            f"WebKit page without being charged against the budget.",+            file=sys.stderr,+        )+        for problem in uncapped:+            print(f"  {problem}", file=sys.stderr)+        print(+            "\nswift-testing runs tests concurrently INSIDE one host and does not cap it "+            "(`-parallel-testing-worker-count 1`\nbounds host PROCESSES). Uncapped, the "+            "live-WebKit tests — the slowest in the target — pile up: 226 concurrent\n"+            "WebKit XPC services were alive in the host at the instant it aborted, and "+            "every still-queued test was then\nreported failed without running "+            "(T-2219). Annotate the suite `@Suite(.liveWebKit)`.",+            file=sys.stderr,+        )+        return 1+    print(+        "[webkit-test-isolation] OK: no synchronous test can construct WebKit, "+        "and every live-WebKit suite is capped.\n"+        "  Scope: reachability into the production target is seeded, not followed. "+        "PRODUCTION_WEBKIT_TYPES\n"+        "  and PRODUCTION_WEBKIT_FACTORIES are the boundary — a production type that "+        "builds a page but is\n"+        "  not listed there is invisible to both rules. Add one there in the change "+        "that introduces it."+    )     return 0  
Tools/Tests/test_webkit_test_isolation.py Modified +240 / -0
diff --git a/Tools/Tests/test_webkit_test_isolation.py b/Tools/Tests/test_webkit_test_isolation.pyindex eb1f96a9..c889b553 100644--- a/Tools/Tests/test_webkit_test_isolation.py+++ b/Tools/Tests/test_webkit_test_isolation.py@@ -1374,6 +1374,37 @@ class SeedTests(unittest.TestCase):         finally:             repo.cleanup() +    def test_seed_check_accepts_a_type_grounded_through_another_listed_type(self):+        # `SVGRenderer` builds no `WKWebView`; it builds a `WebViewPool`, which+        # does. Requiring a DIRECT construction would make that seed unlistable,+        # and leaving it off the list is what left `SVGRendererTests` uncapped.+        repo = FakeRepo({"SampleTests.swift": SYNC_NO_WEBKIT})+        try:+            first, second = list(guard.PRODUCTION_WEBKIT_TYPES)[:2]+            (repo.root / guard.PRODUCTION_WEBKIT_TYPES[first]).write_text(+                f"let indirect = {second}()\n", encoding="utf-8"+            )+            self.assertEqual(guard.verify_seeds(repo.root), [])+        finally:+            repo.cleanup()++    def test_seed_check_rejects_types_that_only_construct_each_other(self):+        # Transitive grounding must not let two dead entries keep each other+        # alive: neither reaches WebKit, so both are stale.+        repo = FakeRepo({"SampleTests.swift": SYNC_NO_WEBKIT})+        try:+            first, second = list(guard.PRODUCTION_WEBKIT_TYPES)[:2]+            (repo.root / guard.PRODUCTION_WEBKIT_TYPES[first]).write_text(+                f"let a = {second}()\n", encoding="utf-8"+            )+            (repo.root / guard.PRODUCTION_WEBKIT_TYPES[second]).write_text(+                f"let b = {first}()\n", encoding="utf-8"+            )+            problems = guard.verify_seeds(repo.root)+            self.assertEqual(len(problems), 2, problems)+        finally:+            repo.cleanup()+     def test_seed_check_counts_a_construction_written_as_real_code(self):         # The mirror of the case above: a construction that is actual code, not         # merely mentioned nearby in a comment/string, must still count.@@ -1516,5 +1547,214 @@ class RepositoryTests(unittest.TestCase):             self.assertEqual(parsed, declared, path.relative_to(root))  ++def budget_problems(sources: dict) -> list[str]:+    """Runs the live-page budget rule over a throwaway tree."""+    repo = FakeRepo(sources)+    try:+        (repo.root / guard.BUDGET_FILE).parent.mkdir(parents=True, exist_ok=True)+        (repo.root / guard.BUDGET_FILE).write_text(+            "static var liveWebKit: Self { LiveWebKitTrait() }\n"+            "enum LiveWebKitBudget { static let semaphore = AsyncSemaphore(limit: 32) }\n",+            encoding="utf-8",+        )+        return guard.verify_budget_coverage(repo.root)+    finally:+        repo.cleanup()+++class BudgetCoverageTests(unittest.TestCase):+    """A suite that can hold a live page must be charged against the budget.++    The rule exists because the abort needs the CONDITION as well as the trigger:+    226 concurrent WebKit XPC services were alive in the host at the instant it+    died. The annotation is the cap; an uncapped new suite silently restores the+    condition, and new live-WebKit suites are added regularly — one was added on+    main while this very change was being written, and this check is what found+    it.+    """++    def test_flags_an_unannotated_suite_that_reaches_webkit(self):+        problems = budget_problems({+            "T.swift": (+                "@MainActor\n"+                "struct LiveTests {\n"+                "    @Test func page() async { _ = WebPage() }\n"+                "}\n"+            )+        })+        self.assertEqual(len(problems), 1, problems)+        self.assertIn("LiveTests", problems[0])++    def test_accepts_a_suite_annotated_on_its_own_line(self):+        self.assertEqual(budget_problems({+            "T.swift": (+                "@Suite(.liveWebKit)\n"+                "@MainActor\n"+                "struct LiveTests {\n"+                "    @Test func page() async { _ = WebPage() }\n"+                "}\n"+            )+        }), [])++    def test_accepts_the_trait_alongside_an_existing_suite_argument(self):+        # The spelling this repo already uses: a named suite that also carries+        # `.serialized`. Reading only a bare `@Suite(.liveWebKit)` would report+        # every one of those as uncovered.+        self.assertEqual(budget_problems({+            "T.swift": (+                '@Suite("Live", .serialized, .liveWebKit)\n'+                "@MainActor\n"+                "struct LiveTests {\n"+                "    @Test func page() async { _ = WebPage() }\n"+                "}\n"+            )+        }), [])++    def test_accepts_the_trait_on_the_test_itself(self):+        self.assertEqual(budget_problems({+            "T.swift": (+                "@MainActor\n"+                "struct LiveTests {\n"+                "    @Test(.liveWebKit) func page() async { _ = WebPage() }\n"+                "}\n"+            )+        }), [])++    def test_a_per_test_trait_does_not_leak_onto_the_next_test(self):+        # `@Test(.liveWebKit) func covered()` written on the line directly above+        # `@Test func uncovered()` is a COMPLETE other declaration, and reading+        # the run of `@`-prefixed lines above a declaration walks straight into+        # it. The trait then covers a test nobody annotated — a false negative,+        # which is the direction that reopens the abort (T-2219 review).+        problems = budget_problems({+            "T.swift": (+                "@MainActor\n"+                "struct LiveTests {\n"+                "    @Test(.liveWebKit) func covered() async { _ = WebPage() }\n"+                "    @Test func uncovered() async { _ = WebPage() }\n"+                "}\n"+            )+        })+        self.assertEqual(len(problems), 1, problems)+        self.assertIn("uncovered", problems[0])++    def test_a_nested_type_does_not_inherit_a_namesakes_annotation(self):+        # Keying the declaration map on the bare terminal name collapses+        # `Wrapper.Live` onto a top-level `Live`, so the nested suite is credited+        # with an annotation written on a type it has nothing to do with.+        problems = budget_problems({+            "T.swift": (+                "@Suite(.liveWebKit)\n"+                "@MainActor\n"+                "struct Live {\n"+                "    @Test func page() async { _ = WebPage() }\n"+                "}\n"+                "\n"+                "@MainActor\n"+                "struct Wrapper {\n"+                "    @MainActor\n"+                "    struct Live {\n"+                "        @Test func page() async { _ = WebPage() }\n"+                "    }\n"+                "}\n"+            )+        })+        self.assertEqual(len(problems), 1, problems)+        self.assertIn("Wrapper.Live", problems[0])++    def test_an_annotated_outer_suite_covers_a_nested_one(self):+        # The mirror: `LiveWebKitTrait.isRecursive` is true, so an annotation on+        # an enclosing suite really does cover what is inside it. Reporting that+        # as uncovered would be a false positive that pushes people to annotate+        # twice.+        self.assertEqual(budget_problems({+            "T.swift": (+                "@Suite(.liveWebKit)\n"+                "@MainActor\n"+                "struct Wrapper {\n"+                "    @MainActor\n"+                "    struct Live {\n"+                "        @Test func page() async { _ = WebPage() }\n"+                "    }\n"+                "}\n"+            )+        }), [])++    def test_ignores_a_suite_that_touches_no_webkit(self):+        self.assertEqual(budget_problems({+            "T.swift": (+                "@MainActor\n"+                "struct PlainTests {\n"+                "    @Test func adds() async { #expect(1 + 1 == 2) }\n"+                "}\n"+            )+        }), [])++    def test_covers_a_suite_that_reaches_webkit_only_through_a_shared_harness(self):+        # The reachability model is shared with the isolation rule on purpose: a+        # suite that builds its page through `SpikeWebPageHarness` never mentions+        # a WebKit type, and a rule that looked only for constructors would leave+        # the majority of the live suites uncapped.+        problems = budget_problems({+            "T.swift": (+                "@MainActor\n"+                "struct HarnessTests {\n"+                "    @Test func page() async throws {\n"+                "        _ = try SpikeWebPageHarness.makePage(html: \"\")\n"+                "    }\n"+                "}\n"+            )+        })+        self.assertEqual(len(problems), 1, problems)++    def test_a_deleted_budget_is_not_a_pass(self):+        # Without this, the rule is satisfiable by deleting the cap: every suite+        # keeps its annotation and the coverage check stays green while nothing+        # bounds anything.+        repo = FakeRepo({})+        try:+            self.assertNotEqual(guard.verify_budget_exists(repo.root), [])+        finally:+            repo.cleanup()++    def test_a_budget_without_a_semaphore_is_not_a_pass(self):+        repo = FakeRepo({})+        try:+            path = repo.root / guard.BUDGET_FILE+            path.parent.mkdir(parents=True, exist_ok=True)+            path.write_text("static var liveWebKit: Self { LiveWebKitTrait() }\n", encoding="utf-8")+            self.assertNotEqual(guard.verify_budget_exists(repo.root), [])+        finally:+            repo.cleanup()++    def test_a_scope_that_takes_no_permit_is_not_a_pass(self):+        # The trait can be gutted from the inside: keep the semaphore, keep every+        # suite's annotation, delete the acquire from the only thing the trait+        # actually runs, and nothing is capped while both other assertions stay+        # green. Mirrors the Swift-side companion assertion in+        # `PrismDocSchemeTaskSinkContractTests`.+        repo = FakeRepo({})+        try:+            path = repo.root / guard.BUDGET_FILE+            path.parent.mkdir(parents=True, exist_ok=True)+            path.write_text(+                "static var liveWebKit: Self { LiveWebKitTrait() }\n"+                "let semaphore = AsyncSemaphore(limit: 32)\n"+                "func provideScope() async throws { try await function() }\n",+                encoding="utf-8",+            )+            problems = guard.verify_budget_exists(repo.root)+            self.assertEqual(len(problems), 1, problems)+            self.assertIn("semaphore.acquire", problems[0])+        finally:+            repo.cleanup()++    def test_the_real_repository_is_fully_capped(self):+        root = TOOLS_DIR.parent+        self.assertEqual(guard.verify_budget_exists(root), [])+        self.assertEqual(guard.verify_budget_coverage(root), [])++ if __name__ == "__main__":     sys.exit(0 if unittest.main(exit=False).result.wasSuccessful() else 1)
Tools/check-test-results.sh Modified +14 / -1
diff --git a/Tools/check-test-results.sh b/Tools/check-test-results.shindex 64855efb..c2458bc7 100755--- a/Tools/check-test-results.sh+++ b/Tools/check-test-results.sh@@ -795,7 +795,20 @@ if [ "$FAILED" -gt 0 ]; then         echo "  of which ~$CASCADE never ran (no recorded duration — the host crashed" >&2         echo "  mid-run and these were still queued; they are not results)" >&2         echo "  ~$REAL look like genuine failures — investigate those first, and find the crasher" >&2-        echo "  (a crash report under ~/Library/Logs/DiagnosticReports/prism-*.ips names it)" >&2+        # Where to look, and what the abort looks like when you get there. Worth+        # spelling out because the obvious in-process remedy does not work: the+        # host does NOT reach `NSSetUncaughtExceptionHandler`. WebKit raises an+        # NSException on a Swift concurrency job, and the unwinder hits a Swift+        # frame, so `_swift_exceptionPersonality` calls `swift::fatalError` and+        # aborts inside the throw — before any uncaught-exception hook runs+        # (measured on prism-2026-08-30-213219.ips; a recorder written for this+        # was removed once it was shown it could never fire). The crash report is+        # therefore the only witness, and it rotates away within days, so read it+        # NOW rather than after the next run overwrites the bundle.+        echo "  find the crasher in ~/Library/Logs/DiagnosticReports/prism-*.ips — the signature is" >&2+        echo "  abort <- swift::fatalError <- _swift_exceptionPersonality <- objc_exception_throw <-" >&2+        echo "  +[NSException raise:format:] <- WebKit <- runJobInEstablishedExecutorContext, i.e. a" >&2+        echo "  WebKit API raising into a Swift async job on the main thread (T-2219)" >&2     fi      echo "FAIL [$LABEL]: $FAILED test(s) failed." >&2
prismTests/WebRendering/PrismDocSchemeHandlerTests.swift Modified +110 / -0
diff --git a/prismTests/WebRendering/PrismDocSchemeHandlerTests.swift b/prismTests/WebRendering/PrismDocSchemeHandlerTests.swiftindex 720c78e0..000b23d9 100644--- a/prismTests/WebRendering/PrismDocSchemeHandlerTests.swift+++ b/prismTests/WebRendering/PrismDocSchemeHandlerTests.swift@@ -16,6 +16,7 @@  import Foundation import Testing+import WebKit @testable import prism  // MARK: - Routing (Req 8.3: /document, /assets, /img)@@ -217,3 +218,112 @@ struct PrismDocSchemeHandlerImagePolicyTests {         #expect(!PrismDocSchemeHandler.isImagePolicyAllowed(resolved))     } }++// MARK: - Stopped-task safety (T-2219)++/// Pins the invariant behind the hardening against a stopped `WKURLSchemeTask`+/// aborting the process: nothing in this handler may hand a result to WebKit+/// except through the stop-aware sink.+///+/// A source-contract test rather than a behavioural one, and deliberately so. The+/// defect is a race between WebKit stopping a task and this handler's producer+/// `Task` reaching its next yield, and the losing side is an+/// `NSInternalInconsistencyException` raised by WebKit that ABORTS THE PROCESS —+/// `_swift_exceptionPersonality` calls `swift::fatalError` as it unwinds into a+/// Swift frame, so it is catchable by nothing and would take this suite, the test+/// host, and every still-queued test with it. A test that reproduced the race+/// would BE the outage it is testing for. What can be checked is the structural+/// property the hardening rests on: every production point stops producing once+/// the producing task is cancelled, which holds exactly when every production+/// point goes through `SchemeTaskSink`.+///+/// Note what this does NOT claim. The sink narrows the window; it does not close+/// it — the cancel signal reaches the producer from `continuation.onTermination`,+/// i.e. only once the continuation is already inert (see `SchemeTaskSink`'s own+/// documentation). This test pins that the narrowing is applied at every+/// production point, not that the race is gone.+struct PrismDocSchemeTaskSinkContractTests {++    private static let sinkType = "private struct SchemeTaskSink"++    private func handlerSource() throws -> String {+        let url = URL(fileURLWithPath: #filePath)+            .deletingLastPathComponent()   // WebRendering+            .deletingLastPathComponent()   // prismTests+            .deletingLastPathComponent()   // repo root+            .appendingPathComponent("prism/Services/WebRendering/PrismDocSchemeHandler.swift")+        return try String(contentsOf: url, encoding: .utf8)+    }++    @Test("every yield and finish outside the sink goes through the sink")+    func productionOnlyThroughTheSink() throws {+        let source = try handlerSource()+        guard let sinkStart = source.range(of: Self.sinkType) else {+            Issue.record("SchemeTaskSink is gone, so nothing bounds production after a stop")+            return+        }+        // The sink's own body is where `continuation.yield`/`finish` belong; it+        // ends at the next top-level `// MARK:`.+        let afterSink = source[sinkStart.lowerBound...]+        guard let sinkEnd = afterSink.range(of: "// MARK: - Routing model") else {+            Issue.record("Could not delimit the sink; update this test with the file's layout")+            return+        }+        let outside = source.replacingOccurrences(+            of: String(afterSink[..<sinkEnd.lowerBound]), with: ""+        )+        for token in ["continuation.yield(", "continuation.finish("] {+            #expect(+                !outside.contains(token),+                """+                `\(token)` is called outside SchemeTaskSink. A result delivered to a \+                WKURLSchemeTask that WebKit has already stopped raises \+                NSInternalInconsistencyException and aborts the process (T-2219). \+                Route it through the sink.+                """+            )+        }+    }++    @Test("the sink still reaches the continuation, so the guard is not gutted")+    func theSinkItselfProduces() throws {+        let source = try handlerSource()+        guard let sinkStart = source.range(of: Self.sinkType),+              let sinkEnd = source.range(of: "// MARK: - Routing model") else {+            Issue.record("Could not delimit the sink")+            return+        }+        let sink = String(source[sinkStart.lowerBound..<sinkEnd.lowerBound])+        // The companion to the rule above: without this, the rule is satisfiable+        // by emptying the sink, and then the handler serves nothing at all while+        // both checks stay green.+        #expect(sink.contains("continuation.yield("))+        #expect(sink.contains("continuation.finish("))+        #expect(sink.contains("Task.isCancelled"))+    }++    @Test("the happy path still produces a response, a body, and a finish")+    func documentRouteStillServes() async throws {+        let handler = PrismDocSchemeHandler(documentHTMLProvider: { "<html>ok</html>" })+        let url = try #require(URL(string: "prism-doc://document/session?rev=1"))+        var results: [URLSchemeTaskResult] = []+        for try await result in handler.reply(for: URLRequest(url: url)) {+            results.append(result)+        }+        #expect(results.count == 2, "response + body")+        if case .data(let data) = results.last {+            #expect(String(bytes: data, encoding: .utf8) == "<html>ok</html>")+        } else {+            Issue.record("Expected the body as the last result, got \(results)")+        }+    }++    @Test("a rejected route still fails the task rather than finishing silently")+    func rejectedRouteStillThrows() async throws {+        let handler = PrismDocSchemeHandler()+        let url = try #require(URL(string: "prism-doc://img/blocked"))+        await #expect(throws: (any Error).self) {+            for try await _ in handler.reply(for: URLRequest(url: url)) {}+        }+    }+}
prismTests/SVGRendererTests.swift Modified +5 / -2
diff --git a/prismTests/SVGRendererTests.swift b/prismTests/SVGRendererTests.swiftindex cdfd4b9f..06d6baaa 100644--- a/prismTests/SVGRendererTests.swift+++ b/prismTests/SVGRendererTests.swift@@ -19,19 +19,20 @@ import AppKit /// /// Note: WKWebView-dependent tests require a host app with a window /// hierarchy and are marked as disabled in the unit test target.+@Suite(.liveWebKit) @MainActor struct SVGRendererTests {      // MARK: - Initialization      @Test("SVGRenderer initializes with default parameters")-    func initializesWithDefaults() {+    func initializesWithDefaults() async {         let renderer = SVGRenderer()         _ = renderer     }      @Test("SVGRenderer accepts custom maxConcurrent and timeout")-    func initializesWithCustomParameters() {+    func initializesWithCustomParameters() async {         let renderer = SVGRenderer(maxConcurrent: 2, timeoutSeconds: 5)         _ = renderer     }@@ -157,7 +158,7 @@ struct SVGRendererTests {     // MARK: - Clear      @Test("Clear does not crash on empty renderer")-    func clearOnEmpty() {+    func clearOnEmpty() async {         let renderer = SVGRenderer()         renderer.clear()     }
45 further test files — @Suite(.liveWebKit) annotations Modified mechanical
diff --git a/prismTests/DocumentFlowCoordinatorImageWindowTests.swift b/prismTests/DocumentFlowCoordinatorImageWindowTests.swiftindex 545786b8..0c0710f1 100644--- a/prismTests/DocumentFlowCoordinatorImageWindowTests.swift+++ b/prismTests/DocumentFlowCoordinatorImageWindowTests.swift@@ -22,7 +22,7 @@ import Foundation import Testing @testable import prism -@Suite("DocumentFlowCoordinator image window lifecycle", .serialized)+@Suite("DocumentFlowCoordinator image window lifecycle", .serialized, .liveWebKit) @MainActor struct DocumentFlowCoordinatorImageWindowTests { diff --git a/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift b/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swiftindex 0d9167c5..90c17c7c 100644--- a/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift+++ b/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift@@ -35,7 +35,7 @@ import Foundation import Testing @testable import prism -@Suite("DocumentFlowCoordinator replace-document window cleanup", .serialized)+@Suite("DocumentFlowCoordinator replace-document window cleanup", .serialized, .liveWebKit) @MainActor struct DocumentFlowCoordinatorReplaceWindowCleanupTests { diff --git a/prismTests/MermaidRendererTests.swift b/prismTests/MermaidRendererTests.swiftindex 19b431b5..423eba8c 100644--- a/prismTests/MermaidRendererTests.swift+++ b/prismTests/MermaidRendererTests.swift@@ -25,6 +25,7 @@ import AppKit /// Note: These are integration tests that require the full WebView rendering pipeline. /// They must run on the main actor due to UI/WebKit requirements. /// Some tests may be flaky in CI environments due to WKWebView needing a window hierarchy.+@Suite(.liveWebKit) @MainActor struct MermaidRendererTests { diff --git a/prismTests/WebRendering/DocumentLoadingIndicatorWiringTests.swift b/prismTests/WebRendering/DocumentLoadingIndicatorWiringTests.swiftindex 78f713b7..3651b37e 100644--- a/prismTests/WebRendering/DocumentLoadingIndicatorWiringTests.swift+++ b/prismTests/WebRendering/DocumentLoadingIndicatorWiringTests.swift@@ -68,7 +68,7 @@ import WebKit @testable import prism  @MainActor-@Suite("Document loading indicator wiring (T-1744)", .serialized)+@Suite("Document loading indicator wiring (T-1744)", .serialized, .liveWebKit) struct DocumentLoadingIndicatorWiringTests {      // MARK: - Helpersdiff --git a/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift b/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swiftindex bfd8ecd4..9624f33b 100644--- a/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift+++ b/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift@@ -18,6 +18,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct HTMLCommentVisibilityLiveTests { diff --git a/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift b/prismTests/WebRendering/WebCollapsedSectionScrollTests.swiftindex 489048c4..9965295d 100644--- a/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift+++ b/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift@@ -24,6 +24,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebCollapsedSectionScrollTests { diff --git a/prismTests/WebRendering/WebContentTerminationWiringTests.swift b/prismTests/WebRendering/WebContentTerminationWiringTests.swiftindex 9750c60d..4d85b755 100644--- a/prismTests/WebRendering/WebContentTerminationWiringTests.swift+++ b/prismTests/WebRendering/WebContentTerminationWiringTests.swift@@ -57,6 +57,7 @@ private final class SubscriptionCounter {     var count = 0 } +@Suite(.liveWebKit) @MainActor struct WebContentTerminationWiringTests { diff --git a/prismTests/WebRendering/WebContrastBridgeTests.swift b/prismTests/WebRendering/WebContrastBridgeTests.swiftindex 20df6ea1..f21b58f7 100644--- a/prismTests/WebRendering/WebContrastBridgeTests.swift+++ b/prismTests/WebRendering/WebContrastBridgeTests.swift@@ -22,7 +22,7 @@ import Testing import WebKit @testable import prism -@Suite("Increase Contrast reaches the rendered document (T-1829)")+@Suite("Increase Contrast reaches the rendered document (T-1829)", .liveWebKit) @MainActor struct WebContrastBridgeTests { diff --git a/prismTests/WebRendering/WebDeliberateChangeTests.swift b/prismTests/WebRendering/WebDeliberateChangeTests.swiftindex 2d4d9ee8..69d7c776 100644--- a/prismTests/WebRendering/WebDeliberateChangeTests.swift+++ b/prismTests/WebRendering/WebDeliberateChangeTests.swift@@ -90,7 +90,7 @@ struct WebDeliberateChangeTests {  // MARK: - Live: disallowed raw HTML is inert in a real WebPage -@Suite("Web Deliberate Change — Live")+@Suite("Web Deliberate Change — Live", .liveWebKit) @MainActor struct WebDeliberateChangeLiveTests { diff --git a/prismTests/WebRendering/WebDetailsNavigationOrderingTests.swift b/prismTests/WebRendering/WebDetailsNavigationOrderingTests.swiftindex 294ce6d2..10462173 100644--- a/prismTests/WebRendering/WebDetailsNavigationOrderingTests.swift+++ b/prismTests/WebRendering/WebDetailsNavigationOrderingTests.swift@@ -29,7 +29,7 @@ import WebKit @testable import prism  @MainActor-@Suite("TOC navigation into a closed <details> survives command reordering (T-1928)")+@Suite("TOC navigation into a closed <details> survives command reordering (T-1928)", .liveWebKit) struct WebDetailsNavigationOrderingTests {      /// A document whose LAST block is a closed `<details>` holding a heading anddiff --git a/prismTests/WebRendering/WebDocumentBridgeLiveTests.swift b/prismTests/WebRendering/WebDocumentBridgeLiveTests.swiftindex 8916b2b3..deb9e993 100644--- a/prismTests/WebRendering/WebDocumentBridgeLiveTests.swift+++ b/prismTests/WebRendering/WebDocumentBridgeLiveTests.swift@@ -18,6 +18,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebDocumentBridgeLiveTests { diff --git a/prismTests/WebRendering/WebDocumentControllerTests.swift b/prismTests/WebRendering/WebDocumentControllerTests.swiftindex e6fec5ee..4a1017e7 100644--- a/prismTests/WebRendering/WebDocumentControllerTests.swift+++ b/prismTests/WebRendering/WebDocumentControllerTests.swift@@ -15,6 +15,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebDocumentControllerTests { diff --git a/prismTests/WebRendering/WebFootnotePopoverTests.swift b/prismTests/WebRendering/WebFootnotePopoverTests.swiftindex ae77d8b1..aef8b44d 100644--- a/prismTests/WebRendering/WebFootnotePopoverTests.swift+++ b/prismTests/WebRendering/WebFootnotePopoverTests.swift@@ -20,6 +20,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebFootnotePopoverTests { diff --git a/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift b/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swiftindex 5fe173eb..f8534a31 100644--- a/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift+++ b/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift@@ -35,6 +35,7 @@ import SwiftUI import Testing @testable import prism +@Suite(.liveWebKit) @MainActor struct WebFragmentNavigationPrecedenceTests { diff --git a/prismTests/WebRendering/WebHiddenSectionGuardTests.swift b/prismTests/WebRendering/WebHiddenSectionGuardTests.swiftindex 0d2fe771..3a701299 100644--- a/prismTests/WebRendering/WebHiddenSectionGuardTests.swift+++ b/prismTests/WebRendering/WebHiddenSectionGuardTests.swift@@ -42,6 +42,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebHiddenSectionGuardTests { diff --git a/prismTests/WebRendering/WebListItemNoteDisplayTests.swift b/prismTests/WebRendering/WebListItemNoteDisplayTests.swiftindex f33d1c11..cb6454d7 100644--- a/prismTests/WebRendering/WebListItemNoteDisplayTests.swift+++ b/prismTests/WebRendering/WebListItemNoteDisplayTests.swift@@ -26,7 +26,7 @@ import Testing import WebKit @testable import prism -@Suite("List-item note display (T-1745)")+@Suite("List-item note display (T-1745)", .liveWebKit) @MainActor struct WebListItemNoteDisplayTests { diff --git a/prismTests/WebRendering/WebMediaBehaviourTests.swift b/prismTests/WebRendering/WebMediaBehaviourTests.swiftindex 9c395909..6e43f497 100644--- a/prismTests/WebRendering/WebMediaBehaviourTests.swift+++ b/prismTests/WebRendering/WebMediaBehaviourTests.swift@@ -13,6 +13,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebMediaBehaviourTests { diff --git a/prismTests/WebRendering/WebNoteAccessibilityTests.swift b/prismTests/WebRendering/WebNoteAccessibilityTests.swiftindex 5e741fb6..29087544 100644--- a/prismTests/WebRendering/WebNoteAccessibilityTests.swift+++ b/prismTests/WebRendering/WebNoteAccessibilityTests.swift@@ -33,7 +33,7 @@ import Testing import WebKit @testable import prism -@Suite("Web note chrome keyboard + screen-reader semantics (T-1725)")+@Suite("Web note chrome keyboard + screen-reader semantics (T-1725)", .liveWebKit) @MainActor struct WebNoteAccessibilityTests { diff --git a/prismTests/WebRendering/WebNotesBehaviourTests.swift b/prismTests/WebRendering/WebNotesBehaviourTests.swiftindex c3a92232..a0c5f88b 100644--- a/prismTests/WebRendering/WebNotesBehaviourTests.swift+++ b/prismTests/WebRendering/WebNotesBehaviourTests.swift@@ -20,6 +20,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebNotesBehaviourTests { diff --git a/prismTests/WebRendering/WebPageRenderScriptsTests.swift b/prismTests/WebRendering/WebPageRenderScriptsTests.swiftindex a6b7b3f7..a0ce2a18 100644--- a/prismTests/WebRendering/WebPageRenderScriptsTests.swift+++ b/prismTests/WebRendering/WebPageRenderScriptsTests.swift@@ -24,7 +24,7 @@ import WebKit @testable import prism  @MainActor-@Suite("In-page render drivers (mermaid + highlight)", .serialized)+@Suite("In-page render drivers (mermaid + highlight)", .serialized, .liveWebKit) struct WebPageRenderScriptsTests {      /// The document-surface CSP from design.md, verbatim (mirrors PrismDocSchemeHandler).diff --git a/prismTests/WebRendering/WebParityFixtureTests.swift b/prismTests/WebRendering/WebParityFixtureTests.swiftindex f7f46a91..b99911fc 100644--- a/prismTests/WebRendering/WebParityFixtureTests.swift+++ b/prismTests/WebRendering/WebParityFixtureTests.swift@@ -155,7 +155,7 @@ struct WebParityFixtureTests {  // MARK: - Live DOM-extracted text parity (Req 1.1, "DOM-extracted text") -@Suite("Web Parity Fixtures — Live DOM")+@Suite("Web Parity Fixtures — Live DOM", .liveWebKit) @MainActor struct WebParityLiveDOMTests { diff --git a/prismTests/WebRendering/WebPerfProbeTests.swift b/prismTests/WebRendering/WebPerfProbeTests.swiftindex a3b843ea..4229f5c5 100644--- a/prismTests/WebRendering/WebPerfProbeTests.swift+++ b/prismTests/WebRendering/WebPerfProbeTests.swift@@ -20,6 +20,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebPerfProbeRoutingTests { @@ -72,7 +73,7 @@ struct WebPerfProbeRoutingTests {  // MARK: - Live: prism-perf.js reports a sample via the bridge -@Suite("Web Perf Probe — Live")+@Suite("Web Perf Probe — Live", .liveWebKit) @MainActor struct WebPerfProbeLiveTests { diff --git a/prismTests/WebRendering/WebReloadNavigationClaimTests.swift b/prismTests/WebRendering/WebReloadNavigationClaimTests.swiftindex 8e973cca..877c2a1d 100644--- a/prismTests/WebRendering/WebReloadNavigationClaimTests.swift+++ b/prismTests/WebRendering/WebReloadNavigationClaimTests.swift@@ -38,6 +38,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebReloadNavigationClaimTests { diff --git a/prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift b/prismTests/WebRendering/WebSavedClipboardImageSourceTests.swiftindex 17a01529..8a3bd0c1 100644--- a/prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift+++ b/prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift@@ -28,6 +28,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebSavedClipboardImageSourceTests { diff --git a/prismTests/WebRendering/WebScrollIntegrationContractTests.swift b/prismTests/WebRendering/WebScrollIntegrationContractTests.swiftindex b05693f1..690b337f 100644--- a/prismTests/WebRendering/WebScrollIntegrationContractTests.swift+++ b/prismTests/WebRendering/WebScrollIntegrationContractTests.swift@@ -25,6 +25,7 @@ import Foundation import Testing @testable import prism +@Suite(.liveWebKit) @MainActor struct WebScrollIntegrationContractTests { diff --git a/prismTests/WebRendering/WebScrollNavigationTests.swift b/prismTests/WebRendering/WebScrollNavigationTests.swiftindex 962eded8..06fe4861 100644--- a/prismTests/WebRendering/WebScrollNavigationTests.swift+++ b/prismTests/WebRendering/WebScrollNavigationTests.swift@@ -17,6 +17,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebScrollNavigationTests { diff --git a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift b/prismTests/WebRendering/WebScrollPositionRetentionTests.swiftindex be33437a..d7dcc8dd 100644--- a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift+++ b/prismTests/WebRendering/WebScrollPositionRetentionTests.swift@@ -29,7 +29,7 @@ import Testing import WebKit @testable import prism -@Suite("T-1639 web scroll position retention", .serialized)+@Suite("T-1639 web scroll position retention", .serialized, .liveWebKit) @MainActor struct WebScrollPositionRetentionTests { diff --git a/prismTests/WebRendering/WebScrollabilityReportingTests.swift b/prismTests/WebRendering/WebScrollabilityReportingTests.swiftindex 4b342972..0ebb7b2c 100644--- a/prismTests/WebRendering/WebScrollabilityReportingTests.swift+++ b/prismTests/WebRendering/WebScrollabilityReportingTests.swift@@ -28,6 +28,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebScrollabilityReportingTests { diff --git a/prismTests/WebRendering/WebSearchBridgeTests.swift b/prismTests/WebRendering/WebSearchBridgeTests.swiftindex 44d5338b..470392b4 100644--- a/prismTests/WebRendering/WebSearchBridgeTests.swift+++ b/prismTests/WebRendering/WebSearchBridgeTests.swift@@ -21,6 +21,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebSearchBridgeTests { diff --git a/prismTests/WebRendering/WebSearchParityTests.swift b/prismTests/WebRendering/WebSearchParityTests.swiftindex 43f1b6c2..a79ce25a 100644--- a/prismTests/WebRendering/WebSearchParityTests.swift+++ b/prismTests/WebRendering/WebSearchParityTests.swift@@ -15,7 +15,7 @@ import Testing import WebKit @testable import prism -@Suite("Web Search Parity")+@Suite("Web Search Parity", .liveWebKit) @MainActor struct WebSearchParityTests { diff --git a/prismTests/WebRendering/WebSearchReloadTests.swift b/prismTests/WebRendering/WebSearchReloadTests.swiftindex fd066e3a..ed3e982f 100644--- a/prismTests/WebRendering/WebSearchReloadTests.swift+++ b/prismTests/WebRendering/WebSearchReloadTests.swift@@ -28,7 +28,7 @@ import Foundation import Testing @testable import prism -@Suite("T-1751 search highlights across reload", .serialized)+@Suite("T-1751 search highlights across reload", .serialized, .liveWebKit) @MainActor struct WebSearchReloadTests { diff --git a/prismTests/WebRendering/WebSearchScrollOwnershipTests.swift b/prismTests/WebRendering/WebSearchScrollOwnershipTests.swiftindex 48884ba2..8757fe15 100644--- a/prismTests/WebRendering/WebSearchScrollOwnershipTests.swift+++ b/prismTests/WebRendering/WebSearchScrollOwnershipTests.swift@@ -28,6 +28,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebSearchScrollOwnershipTests { diff --git a/prismTests/WebRendering/WebSearchWiringTests.swift b/prismTests/WebRendering/WebSearchWiringTests.swiftindex 027107c6..d2bb0c3c 100644--- a/prismTests/WebRendering/WebSearchWiringTests.swift+++ b/prismTests/WebRendering/WebSearchWiringTests.swift@@ -26,7 +26,7 @@ import Testing import WebKit @testable import prism -@Suite("T-1680 web search highlight wiring", .serialized)+@Suite("T-1680 web search highlight wiring", .serialized, .liveWebKit) @MainActor struct WebSearchWiringTests { diff --git a/prismTests/WebRendering/WebSecurityRegressionTests.swift b/prismTests/WebRendering/WebSecurityRegressionTests.swiftindex dc2b29ee..5d59e3d4 100644--- a/prismTests/WebRendering/WebSecurityRegressionTests.swift+++ b/prismTests/WebRendering/WebSecurityRegressionTests.swift@@ -341,7 +341,7 @@ let webAttackFixtures: [WebAttackFixture] = [ // MARK: - Suite  @MainActor-@Suite("Web security regression (Req 8.5)", .serialized)+@Suite("Web security regression (Req 8.5)", .serialized, .liveWebKit) struct WebSecurityRegressionTests {      // MARK: Live attack-class fixtures (emitted/sanitized path)diff --git a/prismTests/WebRendering/WebSelectionNoteTests.swift b/prismTests/WebRendering/WebSelectionNoteTests.swiftindex fae95110..218505fa 100644--- a/prismTests/WebRendering/WebSelectionNoteTests.swift+++ b/prismTests/WebRendering/WebSelectionNoteTests.swift@@ -21,6 +21,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebSelectionNoteTests { diff --git a/prismTests/WebRendering/WebSelectionScrollTests.swift b/prismTests/WebRendering/WebSelectionScrollTests.swiftindex b63fd5e6..40722946 100644--- a/prismTests/WebRendering/WebSelectionScrollTests.swift+++ b/prismTests/WebRendering/WebSelectionScrollTests.swift@@ -19,6 +19,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebSelectionScrollTests { diff --git a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swiftindex 6b9017ed..23bbb327 100644--- a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift+++ b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift@@ -24,6 +24,7 @@ import SwiftUI import Testing @testable import prism +@Suite(.liveWebKit) @MainActor struct WebStateSynchronizerAssemblyTests { diff --git a/prismTests/WebRendering/WebStructuredSelectionTests.swift b/prismTests/WebRendering/WebStructuredSelectionTests.swiftindex 4e2aa17c..1fa0254b 100644--- a/prismTests/WebRendering/WebStructuredSelectionTests.swift+++ b/prismTests/WebRendering/WebStructuredSelectionTests.swift@@ -200,6 +200,7 @@ struct WebStructuredSourceMapInvariantTests {  // MARK: - Safe-decline selection behaviour (Decision 8) +@Suite(.liveWebKit) @MainActor struct WebStructuredSelectionTests { diff --git a/prismTests/WebRendering/WebThemeStateSyncTests.swift b/prismTests/WebRendering/WebThemeStateSyncTests.swiftindex e3f565cd..86597f91 100644--- a/prismTests/WebRendering/WebThemeStateSyncTests.swift+++ b/prismTests/WebRendering/WebThemeStateSyncTests.swift@@ -13,6 +13,7 @@ import Testing import WebKit @testable import prism +@Suite(.liveWebKit) @MainActor struct WebThemeStateSyncTests { diff --git a/prismTests/WebRendering/WebTypographyBridgeTests.swift b/prismTests/WebRendering/WebTypographyBridgeTests.swiftindex ca7df68b..67a4f943 100644--- a/prismTests/WebRendering/WebTypographyBridgeTests.swift+++ b/prismTests/WebRendering/WebTypographyBridgeTests.swift@@ -26,7 +26,7 @@ import Testing import WebKit @testable import prism -@Suite("Typography reaches the rendered document (T-1827/T-1828)")+@Suite("Typography reaches the rendered document (T-1827/T-1828)", .liveWebKit) @MainActor struct WebTypographyBridgeTests { diff --git a/prismTests/WebRendering/WebTypographyReflowAnchorTests.swift b/prismTests/WebRendering/WebTypographyReflowAnchorTests.swiftindex 752edb37..4ace5f2d 100644--- a/prismTests/WebRendering/WebTypographyReflowAnchorTests.swift+++ b/prismTests/WebRendering/WebTypographyReflowAnchorTests.swift@@ -30,7 +30,7 @@ import Testing import WebKit @testable import prism -@Suite("Typography reflow re-anchoring (T-1965)", .serialized)+@Suite("Typography reflow re-anchoring (T-1965)", .serialized, .liveWebKit) @MainActor struct WebTypographyReflowAnchorTests { diff --git a/prismTests/WebRenderingSpikes/MermaidCSPSpikeTests.swift b/prismTests/WebRenderingSpikes/MermaidCSPSpikeTests.swiftindex 59d06dfc..65cdf890 100644--- a/prismTests/WebRenderingSpikes/MermaidCSPSpikeTests.swift+++ b/prismTests/WebRenderingSpikes/MermaidCSPSpikeTests.swift@@ -291,7 +291,7 @@ private enum MermaidSpikeError: Error { // MARK: - Spike suite  @MainActor-@Suite("Mermaid CSP spike (go/no-go gate 3)", .serialized)+@Suite("Mermaid CSP spike (go/no-go gate 3)", .serialized, .liveWebKit) struct MermaidCSPSpikeTests {      // MARK: Fixturesdiff --git a/prismTests/WebRenderingSpikes/RunMarkerSourceMapSpikeTests.swift b/prismTests/WebRenderingSpikes/RunMarkerSourceMapSpikeTests.swiftindex b2705ad0..e208a9c9 100644--- a/prismTests/WebRenderingSpikes/RunMarkerSourceMapSpikeTests.swift+++ b/prismTests/WebRenderingSpikes/RunMarkerSourceMapSpikeTests.swift@@ -183,7 +183,7 @@ let spikeRunMarkerCorpus: [SpikeRunCorpusCase] = [ // MARK: - Spike suite  @MainActor-@Suite("Run-marker source-map spike (go/no-go gate 2)", .serialized)+@Suite("Run-marker source-map spike (go/no-go gate 2)", .serialized, .liveWebKit) struct RunMarkerSourceMapSpikeTests {      /// Probe: for every `[data-prism-run]` element, report its live textContent in UTF-16diff --git a/prismTests/WebRenderingSpikes/WebPageSecuritySpikeTests.swift b/prismTests/WebRenderingSpikes/WebPageSecuritySpikeTests.swiftindex 1b35269f..e747b3a1 100644--- a/prismTests/WebRenderingSpikes/WebPageSecuritySpikeTests.swift+++ b/prismTests/WebRenderingSpikes/WebPageSecuritySpikeTests.swift@@ -32,7 +32,7 @@ private struct EnvironmentProbe: Codable { }  @MainActor-@Suite("WebPage security spike (go/no-go gate 1)", .serialized)+@Suite("WebPage security spike (go/no-go gate 1)", .serialized, .liveWebKit) struct WebPageSecuritySpikeTests {      // MARK: - Fixturesdiff --git a/prismTests/WebViewPoolTests.swift b/prismTests/WebViewPoolTests.swiftindex bf535f20..7bc2d693 100644--- a/prismTests/WebViewPoolTests.swift+++ b/prismTests/WebViewPoolTests.swift@@ -16,6 +16,7 @@ import WebKit /// /// Note: Tests requiring WKWebView window hierarchy are disabled in the /// unit test target. They pass in the UI test host.+@Suite(.liveWebKit) @MainActor struct WebViewPoolTests { @@ -240,6 +241,7 @@ private final class AtomicInt: @unchecked Sendable { /// - 7.4: Navigation timeout /// /// Note: WKWebView requires a window hierarchy for navigation to complete.+@Suite(.liveWebKit) @MainActor struct WebViewPoolContentLoadingTests { @@ -450,6 +452,7 @@ struct WebViewPoolContentLoadingTests { /// Requirements covered: /// - 9.1, 9.2: Capture at specified width /// - 9.3: Throw on failure+@Suite(.liveWebKit) @MainActor struct WebViewPoolSnapshotTests { @@ -499,6 +502,7 @@ struct WebViewPoolSnapshotTests { /// - 10.1: Nil all pool slots /// - 10.2: Do NOT clear activeSlots (Decision 9) /// - 10.3: Resume pending continuations with CancellationError+@Suite(.liveWebKit) @MainActor struct WebViewPoolClearTests { @@ -588,6 +592,7 @@ struct WebViewPoolClearTests { /// Requirements covered: /// - 13.1: Nil ALL pool slots on termination (Decision 10) /// - 13.2: Throw processTerminated error+@Suite(.liveWebKit) @MainActor struct WebViewPoolProcessTerminationTests { 
specs/bugfixes/webkit-scheme-task-stop-abort/report.md Added +295 / -0
diff --git a/specs/bugfixes/webkit-scheme-task-stop-abort/report.md b/specs/bugfixes/webkit-scheme-task-stop-abort/report.mdnew file mode 100644index 00000000..1de12e09--- /dev/null+++ b/specs/bugfixes/webkit-scheme-task-stop-abort/report.md@@ -0,0 +1,295 @@+# Bugfix Report: The Host Abort That Survived PR #380++**Date:** 2026-08-30+**Status:** Fixed — condition removed, mechanism identified and hardened, not+proven closed (see "What is proven and what is not")+**Transit:** T-2219 (third pass), related: T-2096, T-1541, T-1983, T-2235++## Description of the Issue++`make test-quick` intermittently reports a four-figure failure count in which+almost nothing failed: the shared test host aborts mid-run and every still-queued+test is recorded as `Test crashed with signal abrt` without having executed.+Recorded cascades: 190, 234, 283, 322 and (during this investigation) 132 and 283.++T-2219 was filed against `MermaidCSPSpikeTests`, fixed once as T-1541's+synchronous-construction defect (PR #380, 60 tests made `async` plus+`Tools/check-webkit-test-isolation.py`), and **reopened twice** — the cascade+still reproduced on `main` with `make verify-test-isolation` passing. Both+reopening comments recorded the same crash signature and both times the crash+reports they cited had rotated away before anyone could read them.++## Investigation Summary++**Reproduced first, theorised second.** Six full-suite runs under an unloaded+machine were clean; the seventh, on a machine running two other agents' suites,+reproduced it (`failed=283`, 278 of them `Test crashed with signal abrt`). It+reproduces roughly one run in five under load and essentially never in isolation,+which is why three tickets have each named a different innocent suite.++Two measurements did the work.++**1. The unified log, not the result bundle.** `launchd`'s records for the dead+host (pid 6227) show it started **230 `com.apple.WebKit.*` XPC services** and had+**226 alive simultaneously at 16:15:18.600** — the host logged its last line at+16:15:18.611. The breakdown was ~206 `WebContent` (one per live `WebPage`), 20+`Networking`, 20 `GPU`. The host's soft descriptor limit, inherited from launchd,+is 256.++That looked like the answer and was not: **every clean run peaks at the same+place** (226, 228, 223, 231, 228, 227). So the working set is the CONDITION the+failure needs, not the trigger. It is still pathological — nothing in the suite+needs 206 concurrent pages — and it is why the failure is load-dependent.++Where it comes from: `-parallel-testing-worker-count 1` bounds test HOST+PROCESSES, not swift-testing's in-process concurrency, which is uncapped. The+live-WebKit tests are also the slowest in the target (0.2–25 s, awaiting real+navigations), so they are exactly the ones that pile up; every test suspended on a+navigation still holds its page and its WebContent process.++**2. The crash report, read to the last frame.** One was finally captured fresh+(`prism-2026-08-30-213219.ips`). Main thread, `com.apple.main-thread`:++```+abort+swift::fatalError+_swift_exceptionPersonality        <-- the frame that explains everything+_Unwind_RaiseException+__cxa_throw+objc_exception_throw++[NSException raise:format:]       (CoreFoundation)+WebKit                             (+533980, stripped in the shared cache)+swift::runJobInEstablishedExecutorContext+```++Read upward: a Swift concurrency job on the main actor calls a WebKit API; that+API raises an Objective-C exception with `[NSException raise:format:]`; the+exception unwinds into a Swift frame, and Swift's personality routine refuses —+`swift::fatalError`, `abort()`.++Three consequences follow directly, and each one had been costing time:++- **The abort happens inside the throw.** It never reaches `_objc_terminate`, so+  `NSSetUncaughtExceptionHandler` is never called. An in-process recorder was+  built during this investigation to capture the exception's name and reason; it+  was armed, the abort reproduced, and it logged nothing. That is not a bug in the+  recorder — it is unreachable by construction. It was removed rather than+  shipped.+- **It is catchable by nothing.** Not `try`, not `do/catch`, not a test harness.+- **The blamed test is meaningless.** The job that dies is whichever one is+  resumed at that instant; the result bundle attributed two reproductions to+  `URLEncodingCorpusTests`, which does not touch WebKit.++**Which WebKit API.** The frame is stripped in the dyld shared cache and `atos`+cannot resolve it, so it is named by construction rather than by symbol. It is+reached DIRECTLY from `runJobInEstablishedExecutorContext`, i.e. the job body is+WebKit's own Swift overlay, and it raises through `[NSException raise:format:]`.+The overlay path this app exercises constantly is the `URLSchemeHandler` bridge,+and the WebKit API it drives that raises that way is `WKURLSchemeTask`, which+raises `NSInternalInconsistencyException` when given a response, data, a finish or+a failure **after it has been stopped**.++That matches every other observation: it is a lifetime/teardown path (so the+static constructor guard is blind to it, exactly as the reopening comments said);+it needs a stop to race a pending production (so it is load-dependent and+unreproducible in isolation); and the crashing host's final millisecond of log is+a burst of scheme-task traffic — dozens of main-frame loads failing `-1008`+(`URLError(.resourceUnavailable)`, which is `serveDocument`'s own throw) plus+`WebURLSchemeTaskProxy::didReceiveData`.++## Discovered Root Cause++`PrismDocSchemeHandler.reply(for:)` can deliver to a stopped `WKURLSchemeTask`.++It produces from an unstructured `Task` into an `AsyncThrowingStream` whose+buffering policy is the default `.unbounded`, so a yield does not wait for the+consumer: production and consumption are independent, and WebKit stopping the task+races every yield the producer has not made yet. A stop is routine — navigating+away, a superseded load, the page being released — and a test host that builds and+drops hundreds of pages does it constantly.++Three of the four production points had no cancellation check at all:++- `serveDocument` — `yield(.response)`, `yield(.data)`, `finish()`, unguarded.+- `serveAsset` — the same, unguarded.+- the `.rejected` route — `finish(throwing:)`, which becomes `didFailWithError:`+  on a possibly-stopped task.+- `serveImage` alone called `Task.checkCancellation()`, and only once, before the+  first of its three productions.++## Resolution++**1. Every production point goes through a stop-aware sink** (hardening, not a+closed door — see below). `SchemeTaskSink` is now the only thing in+`PrismDocSchemeHandler` that touches the continuation. Each `yield` and `finish`+stops producing once the producing task is cancelled, and a `finish(throwing:)`+after a stop finishes SILENTLY — a failure delivered to a stopped task raises the+same exception as a response delivered to one, and there is no longer anybody to+tell. `reply(for:)` wires `continuation.onTermination` to cancel the task.++**What that guard is actually worth.** The first version of this report claimed+the handler "now cannot" produce after a stop, and that overstates it. The+producing task is unstructured (`Task { … }`, no parent to propagate+cancellation) and the only `cancel()` is in `continuation.onTermination`.+Termination is what makes the continuation inert, so by the time `Task.isCancelled`+is true, `yield` and `finish` are already no-ops on their own: the guard can only+ever agree with the continuation, never pre-empt it. What the sink does buy, and+all it buys, is that once the consumer is gone the producer stops doing work and+stops appending to an unbounded buffer, so there is less left in that buffer for+WebKit's adapter to drain into a task it has stopped — plus the silent-failure+behaviour above, which is a real change in what gets delivered. The remaining+window lives inside WebKit's own consumer, and nothing in the `URLSchemeHandler`+API can reach it: WebKit offers no way to ask whether a task is still live, only+the termination callback. Read the change as a reduction in exposure, not as a+proof of absence.++**Why not a bounded buffering policy.** The obvious follow-up — swap `.unbounded`+for `.bufferingNewest(n)` — was considered and rejected, because it does not+mean what the buffering argument above needs it to mean. `AsyncStream` /+`AsyncThrowingStream` have **no back-pressure at any policy**: `yield` never+suspends. `.bufferingNewest(n)` and `.bufferingOldest(n)` do not slow the+producer down to the consumer's rate; when the buffer is full they DROP an+element and return `.dropped`. This stream's elements are `.response` followed by+`.data` chunks, so dropping one serves a truncated or headerless document — a+deterministic corruption of every large response, traded for a marginal+narrowing of a rare race. The real fix for the buffer would be back-pressure the+type does not offer (an `AsyncChannel`-style rendezvous), and re-plumbing the+scheme handler onto one is out of scope for a crash fix and would change the+serving path for every document, image and asset. `.unbounded` stays; the honest+statement of the limitation lives in `SchemeTaskSink`'s documentation.++**2. The concurrency budget** (removes the condition).+`LiveWebKitTrait` (`@Suite(.liveWebKit)`) charges every test that can hold a live+page against a 32-permit semaphore. Measured: peak concurrent WebKit helper+processes **226 → 59**, and processes are now reclaimed during the run instead of+accumulating (176 launched / 184 exited in the window, against 230 launched / 4+exited before). Nothing is skipped, excluded or reordered; the executed-test count+is unchanged.++**3. The guard cannot go stale.** `Tools/check-webkit-test-isolation.py` grew a+second rule: a `@Test` that can reach a WebKit constructor must be covered by+`.liveWebKit`. Both rules call one predicate, `_reaches_webkit`, so a suite cannot+be visible to one and invisible to the other (`scan` kept a second copy of that+chain until this branch's review; only the violation *wording* is built+separately now). `verify_budget_exists` refuses the other direction — deleting the+budget, or deleting the `semaphore.acquire` from `provideScope` while the file and+every annotation survive, would otherwise pass. The rule earned itself+immediately: it found `WebSavedClipboardImageSourceTests`, added to `main` while+this change was being written.++**The seed list is the boundary of all of that, and it was too short.**+Reachability into the production target is seeded, not followed:+`PRODUCTION_WEBKIT_TYPES` is an explicit list, and a production type that builds a+page but is not on it is invisible to *both* rules. Review found two missing —+`WebViewPool` and `SVGRenderer` — which is why `SVGRendererTests.acceptSourceAtLimit`+(enabled, driving a real pooled `WKWebView`) was neither checked nor capped. Both+are listed now; `verify_seeds` grounds them transitively, because `SVGRenderer`+builds no `WKWebView` of its own — it builds a `WebViewPool`, which does. Adding+them made the guard flag eight further suites (`SVGRendererTests`, five+`WebViewPoolTests` suites, and the two `DocumentFlowCoordinator*` window suites),+all now annotated, plus three synchronous `SVGRendererTests` bodies, all now+`async`. The boundary is stated in the script's docstring, in its success output,+and in CLAUDE.md, because no check can discover the entry that is missing —+discovering it is the whole-target Swift parser the seed list exists to avoid.++Two false negatives in the coverage rule itself were found in the same review and+fixed with fixtures (both verified red/green against a mutated script): the+attribute walk had no declaration boundary, so a per-test `@Test(.liveWebKit)`+written on the line above the next test leaked its coverage onto it; and the+type-declaration map was keyed on the bare terminal name and could not see a+nested type at all, so a nested `Wrapper.Live` inherited a top-level `Live`'s+annotation. The map is keyed on the full owner path now, and coverage walks the+path outwards because `LiveWebKitTrait.isRecursive` is true.++**4. `check-test-results.sh` names the signature.** The cascade message now tells+the reader what to look for in the `.ips` (`_swift_exceptionPersonality` under+`objc_exception_throw`) and why there is no in-process alternative. This ticket+lost its evidence twice to rotated crash reports and once to an unreachable hook.++### Regression coverage++`PrismDocSchemeTaskSinkContractTests` — a source contract, deliberately. A+behavioural test of this race would BE the outage: the losing side aborts the+process, taking the suite and every queued test with it. What is checkable is the+structural property the hardening rests on — that no production point bypasses the+sink — plus a companion assertion that the sink still reaches the continuation, so+the rule cannot be satisfied by gutting it. Verified red/green against a mutated+source. Two behavioural tests pin that the happy path and the rejection path are+unchanged. It does not pin the absence of the race, and says so.++`Tools/Tests/test_webkit_test_isolation.py` covers the budget rule and the seed+model (83 tests, all passing), including the two false negatives above and the+transitive/cyclic cases of `verify_seeds`.++## What is proven and what is not++- **Proven:** the abort is an ObjC exception raised by WebKit on a main-actor+  Swift job, aborting inside the unwinder; it cannot be caught or recorded+  in-process; the handler could produce after a stop, and now stops producing as+  soon as its consumer is gone; the peak working set fell 226 → 59.+- **Not proven:** that `WKURLSchemeTask` is the specific raising API. The frame is+  stripped and the exception's own text was destroyed with the process. It is+  named by construction — the job is WebKit's own overlay, the overlay this app+  drives is the scheme handler, and that is the API in it that raises this way —+  not by symbol.+- **Not proven, and explicitly not claimed:** that the deliver-after-stop window is+  closed. It is narrowed. The cancel signal arrives from+  `continuation.onTermination`, i.e. after the continuation is already inert, so+  the sink's guards cannot pre-empt a stop — see "What that guard is actually+  worth" above.+- **Not proven:** absence. This is a race that reproduces about one run in five+  under load; a run of clean runs is weak evidence either way, and the honest+  statement is that the one path this repository owns which could reach the+  measured stack now produces far less into a torn-down stream, and the condition+  it needed is 3.8x smaller.+- **Ruled out:** the concurrency count as the trigger — every clean run peaks at+  the same 226.+- **Ruled out:** a bounded buffering policy as an improvement — `AsyncStream` has+  no back-pressure, so it would drop response and body elements rather than slow+  the producer.++## Verification++**Automated:**++- [x] `make verify-test-isolation` — clean, including the two new seed types and+      the eight suites they exposed+- [x] `python3 -m unittest Tools.Tests.test_webkit_test_isolation` — 83 tests, all+      passing. The two new false-negative fixtures were each verified red against a+      mutated script (attribute walk without the declaration boundary; declaration+      map keyed on the bare name) and green after+- [x] `make verify-make-guards` — passes+- [x] `make lint` — clean+- [x] `make test-quick` on the branch — **4712 tests executed, 4669 passed, no+      cascade**. The failures were 3 known T-2235 flakes (plus their retries); no+      test was reported failed without a recorded duration, which is the+      cascade's signature, and `Tools/check-test-results.sh` confirmed a non-zero+      executed count+- [x] Targeted `-only-testing:` runs covering everything this change touched —+      the five `PrismDocSchemeHandler*` suites (`…RoutingTests`, `…CSPTests`,+      `…LocalFileTests`, `…ImagePolicyTests`, `PrismDocSchemeTaskSinkContractTests`),+      `SVGRendererTests`, the five `WebViewPool*` suites and the two+      `DocumentFlowCoordinator*` window suites — each execution-confirmed via+      `Tools/check-test-results.sh`. Note that+      `-only-testing:prismTests/PrismDocSchemeHandlerTests` matches nothing: that+      is the file name, not a suite name, and a filter matching nothing runs zero+      tests and still exits 0++**Manual verification:**++- The process census (`log show --predicate 'process == "launchd"'`, filtered to+  the host pid) is what measured 226 → 59 concurrent WebKit helper processes, and+  that reclamation resumed during the run.++## Files Changed++- `prism/Services/WebRendering/PrismDocSchemeHandler.swift` — `SchemeTaskSink`.+- `prismTests/Support/LiveWebKitBudget.swift` — budget + trait (new).+- 48 test files — `@Suite(.liveWebKit)`.+- `prismTests/SVGRendererTests.swift` — three synchronous bodies made `async`.+- `Tools/check-webkit-test-isolation.py` — budget coverage + existence rules, the+  `WebViewPool`/`SVGRenderer` seeds with transitive grounding, one shared+  reachability predicate, and the two false-negative fixes.+- `Tools/Tests/test_webkit_test_isolation.py` — 83 cases.+- `Tools/check-test-results.sh` — cascade message names the crash signature.+- `prismTests/WebRendering/PrismDocSchemeHandlerTests.swift` — sink contract.
specs/bugfixes/live-webkit-test-host-aborts/report.md Modified +13 / -1
diff --git a/specs/bugfixes/live-webkit-test-host-aborts/report.md b/specs/bugfixes/live-webkit-test-host-aborts/report.mdindex 09987bab..6be9c9ab 100644--- a/specs/bugfixes/live-webkit-test-host-aborts/report.md+++ b/specs/bugfixes/live-webkit-test-host-aborts/report.md@@ -1,7 +1,19 @@ # Bugfix Report: Live-WebKit Test Suites Abort the Shared Test Host  **Date:** 2026-08-22-**Status:** Fixed+**Status:** Superseded in part — this fix stands, but it was not the whole cause.++> **Read this first.** Everything below is correct and still in force: a+> synchronous `@MainActor` test body reaching WebKit really does abort the host,+> sixty of them really existed, and `make verify-test-isolation` still enforces+> the rule. But T-2219 **reopened twice after this landed**, with the guard+> passing, because there is a SECOND mechanism on a lifetime path that no+> constructor check can see — WebKit raising an ObjC exception on a Swift job,+> aborting inside the unwinder — plus a pathological condition (≈206 concurrent+> live pages) that the abort needs and this report never measured. Both are+> diagnosed, measured and fixed in+> `specs/bugfixes/webkit-scheme-task-stop-abort/report.md`. If a cascade turns up+> again, start there, not here.  ## Description of the Issue 
CLAUDE.md Modified +53 / -0
diff --git a/CLAUDE.md b/CLAUDE.mdindex a02411b5..dbf020b1 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -332,6 +332,59 @@ is static because the abort is a scheduling race — the guilty suite passes in isolation every time, and the suite the cascade *names* is usually not the guilty one. See `docs/agent-notes/development-tooling.md`. +That guard covers one of TWO mechanisms, and the second is not a construction at+all (T-2219, third pass). A captured crash report+(`prism-2026-08-30-213219.ips`) reads, main thread, bottom-up:+`runJobInEstablishedExecutorContext` -> WebKit -> `+[NSException raise:format:]`+-> `objc_exception_throw` -> **`_swift_exceptionPersonality`** ->+`swift::fatalError` -> `abort`. A WebKit API raises an ObjC exception on a Swift+async job; the exception unwinds into a Swift frame; Swift's personality routine+refuses and aborts **inside the throw**. Three things follow, and each one cost+this ticket a pass: nothing catches it (`try`/`catch` cannot, and it never+reaches `_objc_terminate`, so `NSSetUncaughtExceptionHandler` is unreachable — a+recorder built on it was armed, reproduced the abort, and logged nothing); the+exception's text dies with the process; and the test the bundle blames is+whichever job was resumed at that instant, twice `URLEncodingCorpusTests`, which+touches no WebKit. The path here that reaches it is a `WKURLSchemeTask` given+anything after WebKit stopped it, so `PrismDocSchemeHandler` produces only+through `SchemeTaskSink`, which stops producing once its task is cancelled —+failures included. That is HARDENING, not a closed door, and the distinction is+written into the type: the cancellation arrives from `continuation.onTermination`+on an unstructured task, i.e. only once the continuation is already inert, so the+guard can never pre-empt a stop — what it buys is that the producer stops feeding+an unbounded buffer the consumer has abandoned. A bounded buffering policy is not+the missing piece (`AsyncStream` has no back-pressure at any policy: it would DROP+a `.response` or a `.data` rather than slow the producer, serving a truncated+document). `PrismDocSchemeTaskSinkContractTests` fails the build if a production+point bypasses the sink; a behavioural test is impossible, because reproducing the+race aborts the process running it.++`make verify-test-isolation` also enforces the CONDITION that abort needs.+swift-testing runs tests concurrently inside one host with no cap+(`-parallel-testing-worker-count 1` bounds host PROCESSES), and the live-WebKit+tests are the slowest in the target, so they pile up: 230 WebKit helper+processes started by one host, **226 alive simultaneously** in the instant it+died, 4 ever reclaimed. Every CLEAN run peaks in the same place, so the pile-up+is the condition rather than the trigger — and it is why the abort is+load-dependent and never reproduces a suite in isolation. `@Suite(.liveWebKit)`+(`prismTests/Support/LiveWebKitBudget.swift`) charges a suite against a+32-permit budget; measured peak 226 -> 59. The guard fails when a suite that can+reach WebKit is uncovered, using the SAME predicate as the synchronous rule+(`_reaches_webkit`) so a suite cannot be visible to one and invisible to the+other, and `verify_budget_exists` refuses the other direction (deleting the+budget, or emptying `provideScope` of its `semaphore.acquire`, while every suite+keeps its annotation).++Know the boundary of both rules: reachability into the PRODUCTION target is+seeded, not followed. `PRODUCTION_WEBKIT_TYPES` in the guard is an explicit list,+and a production type that builds a page but is not named there is invisible to+both checks — every test driving it scans clean and its suite is never asked to+carry `.liveWebKit`. Nothing can discover that omission (discovering it is the+whole-target Swift parser the list exists to avoid), and it has already cost+once: `WebViewPool` and `SVGRenderer` were missing, so `SVGRendererTests` drove a+real pooled `WKWebView` unchecked and uncapped until T-2219's review. **When a+production type starts building WebKit, add it to that list in the same change.**+ The fourth way is for the tests never to be scheduled at all. `localisation-tests.yml` is the only workflow that runs `xcodebuild`, and its `paths` filters omitted `prismTests/**`, `prismUITests/**`, `prism.xctestplan`, `prism.xcodeproj/**` and
CHANGELOG.md Modified +5 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex a04cda01..9750fe47 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Changed -- Live-WebKit test suites no longer abort the shared test host, which used to turn a whole run into a fictional four-figure failure count (T-2219, T-2096). One process hosts the entire unit-test target, so an abort reports every still-queued test as a failure it never ran — 189 of them in one observed run, 233 in another. The cause was the one T-1541 diagnosed and fixed for a single suite: a synchronous `@MainActor` test body gets no hop-on-entry in this target's build configuration, so under load it runs on the cooperative pool and WebKit's main-thread assertion kills the process. Sixty tests across nine suites could still do that, including the two the tickets name and two suites no ticket had ever mentioned — which is why the abort kept being attributed to a different suite each time, usually whichever long-running live-WebKit test happened to be in flight. All sixty are now `async`, which hops as part of the ABI, and `make verify-test-isolation` fails the build if a synchronous test can reach WebKit again. Nothing is skipped or excluded; the number of tests executed is unchanged.+- Live-WebKit test suites no longer abort the shared test host, which used to turn a whole run into a fictional four-figure failure count (T-2219, T-2096). One process hosts the entire unit-test target, so an abort reports every still-queued test as a failure it never ran — 189 of them in one observed run, 233 in another. The cause was the one T-1541 diagnosed and fixed for a single suite: a synchronous `@MainActor` test body gets no hop-on-entry in this target's build configuration, so under load it runs on the cooperative pool and WebKit's main-thread assertion kills the process. Sixty tests across nine suites could still do that, including the two the tickets name and two suites no ticket had ever mentioned — which is why the abort kept being attributed to a different suite each time, usually whichever long-running live-WebKit test happened to be in flight. All sixty are now `async`, which hops as part of the ABI, and `make verify-test-isolation` fails the build if a synchronous test can reach WebKit again. Nothing is skipped or excluded; the number of tests executed is unchanged. **This fixed one cause, not the cascade:** T-2219 reopened twice after it landed, with the guard passing, because a second mechanism sits on a lifetime path no constructor check can see. See the two T-2219 entries under Fixed below for what actually closed it. - A change that touches only test files, test target membership, the test plan, Xcode project settings, or a document under `samples/` now runs the test suite in CI instead of merging with a green run that tested none of it (T-2198). The per-locale sweep is the sole workflow that executes tests, and it is paths-filtered; both its `push` and `pull_request` filters omitted `prismTests/**`, `prismUITests/**`, `prism.xctestplan`, `prism.xcodeproj/**` and `samples/**`, so a compile-broken test, a coverage-disabling plan edit, or a sample document the parity suites open at run time produced no build and no test run at all — a gap that is silent by construction, because a skipped workflow looks exactly like nothing needed checking. `samples/**` is worth naming separately because it is the shape the first fix missed: no target compiles it, so a list derived from "what Xcode builds" omits it, while `ParityFixtureSupport`, `SamplesComplianceTests` and `OffMainEmitTests` all read it from disk while the suite runs. The required list is now derived from what the tests OPEN, not what the project compiles. Both filters cover it, and `make verify-workflow-triggers` — run from the unfiltered `checks.yml`, so it cannot itself be bypassed the same way — fails when the two lists drift apart again, when a filter key is one it cannot read (`paths-ignore:`, quoted or not; an inline `paths: [...]`; any unrecognised line under a trigger), when a `!` negation appears (membership cannot see an exclusion, so `['prismTests/**', '!prismTests/**']` listed every required path while triggering on none of them), when a required path no longer exists in the repository, and when `checks.yml` itself grows a paths filter or drops the step that invokes the guard. - CI now runs the test suite instead of only appearing to (T-1983). The per-locale sweep is the only job that can execute tests, and it reported success while executing none: its build failed for want of a signing certificate on the runner, that failure was swallowed, and nothing checked that any test had run. The sweep now signs ad-hoc so the build succeeds without a certificate, every test target hands its result bundle to the zero-test guard — per locale configuration, not once at the end — and any recipe whose failure must be believed carries `$(STRICT)`, because `.SHELLFLAGS` is silently ignored by the GNU Make 3.81 that macOS ships. `make verify-make-guards` asserts all of this on every push. - `Tools/check-test-results.sh` no longer reports OK on two more shapes of run that were not a clean pass (T-2224, T-1993). A test that fails on its first attempt and passes on a retry only ever left its final result in the bundle's counts, so the failure was invisible — measured directly on PR #377, where a macOS run exited 65 with `** TEST FAILED **` while the bundle reported 60/60 passed because two `WebContentTerminationWiringTests` cases needed a retry. The checker now walks the bundle's per-test attempt history and fails the run when any test needed one, naming which. Separately, an interrupted, cancelled, or infrastructure-failed run can leave a readable partial bundle with passing-looking counts and zero recorded failures while its own top-level `result` field sits at "unknown" — never resolving to a verdict at all. The checker now refuses to report success on such a run, and the count-arithmetic sanity check that used to only warn on a mismatch now fails closed, the same as everything else this script guards. That guard distinguishes "did not resolve" from "did not pass": `result` is typed as the same five-value enum as an individual test's result (`Passed`, `Failed`, `Skipped`, `Expected Failure`, `unknown`), so a run resolving to Skipped or Expected Failure is a legitimate outcome and is allowed through — demanding Passed or Failed would have turned an ordinary `-only-testing:` selection that lands entirely on disabled tests into a hard failure blamed on an interrupted test host that never existed. A run that executed nothing because every selected test was skipped still fails, since it verifies exactly as much as running no tests at all, but it now fails as a zero-executed run and says so rather than borrowing the infrastructure-failure diagnosis. The retry scan searches a test case's whole subtree rather than its direct children, which is what makes it work at all on `make test` and `make test-ui`: neither passes `-only-test-configuration`, so every attempt on those runs sits under a `Test Plan Configuration` node instead of directly under the test case, and a direct-children scan finds nothing there — silently, on half the pre-push matrix. It also no longer depends on attempt ordering or on there being more than one attempt: any recorded failed attempt under a test that did not end up failed is the laundering shape, however the attempts are listed. Three further shapes now fail instead of reporting OK, all of them cases where the checker previously drew a clean conclusion from data it had not actually understood: a per-test tree containing a `nodeType` or result value outside the published enums (a one-character drift is enough to make the scan match nothing), a tree with no test-case node in it at all while the summary counts tests, and a bundle that contradicts itself by recording a test case as failed while its `failedTests` count is zero. `make verify-make-guards` now runs a dedicated regression suite (`Tools/Tests/test-check-test-results.sh`) covering these shapes alongside the existing zero-test and cascade-failure cases. One limitation is worth stating outright rather than leaving implied: the per-attempt node shape the retry scan reads (`Repetition` / `Test Case Run`) is derived from `xcresulttool`'s published schema and is **not** confirmed against a captured retried bundle — repeated attempts to produce one on a loaded machine died with `** BUILD INTERRUPTED **`, and a sweep of the readable historical bundles on this machine found none containing such a node. The scan accepts either shape at any depth for that reason, and the fixtures pin its parsing, its descent through the configuration layer, and its control flow — not the attempt-node shape itself. The nesting the fixtures *do* model faithfully (a `Test Plan` root, and test-case nodes whose children are `Test Plan Configuration` nodes) was measured against real bundles from this project.@@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- The shared unit-test host no longer aborts part-way through a run and reports every still-queued test as a failure it never ran (T-2219, third pass). PR #380 fixed one cause of this — a synchronous `@MainActor` test body reaching WebKit off the main thread — and the cascade kept coming back with `make verify-test-isolation` passing, because there was a second, unrelated cause on a lifetime path no constructor check can see. A crash report captured during this investigation named it: WebKit raises an Objective-C exception on a Swift async job on the main thread, and the exception unwinds into a Swift frame, where `_swift_exceptionPersonality` calls `swift::fatalError` and aborts **inside the throw**. That last detail is why the failure had been so expensive to chase: the process dies before `NSSetUncaughtExceptionHandler` or any `catch` can see it, so nothing is recorded, the exception's own text is destroyed with the process, and the test the bundle blames is simply whichever job was resumed at that instant — twice it blamed `URLEncodingCorpusTests`, which does not touch WebKit at all. The path in this repository that can reach that stack is the `prism-doc://` scheme handler: it produced responses from an unstructured task into an unbounded stream buffer, so WebKit stopping a task (navigating away, superseding a load, releasing a page — all routine) raced every response not yet delivered, and a `WKURLSchemeTask` given anything after it has been stopped raises exactly that exception. Three of its four production points had no cancellation check at all. Every one of them now goes through a single sink that stops producing as soon as its consumer is gone, failures included, and a source-contract test fails the build if a new one bypasses it — a behavioural test is impossible here, since reproducing the race aborts the process running it. That sink is hardening rather than a closed door, and the code says so: its cancellation signal arrives only once the stream is already torn down, so it narrows the window instead of removing it. A bounded stream buffer is not the missing piece — `AsyncStream` has no back-pressure at any policy, so bounding it would drop response and body elements rather than slow the producer down.+- Live-WebKit tests no longer hold hundreds of WebKit processes open at once (T-2219). Measured on the run that reproduced the abort: 230 WebKit helper processes started by one test host, 226 of them alive simultaneously in the instant it died, only 4 ever reclaimed — about 206 concurrent live pages. `-parallel-testing-worker-count 1` does not bound this; it bounds test host processes, while swift-testing runs tests concurrently inside one host with no cap, and the live-WebKit tests are the slowest in the target, so they are precisely the ones that accumulate. Every clean run peaked in the same place, so the pile-up is not itself the crash — it is the condition the crash needs, and it is why a run only fails under load and never reproduces a suite in isolation. Suites that can hold a live page are now charged against a shared budget, which took the peak from 226 to 59 and restored reclamation during the run. `make verify-test-isolation` fails when a suite that can reach WebKit is not covered, sharing one reachability model with the existing synchronous-construction rule so a suite cannot be visible to one check and invisible to the other; it found an uncovered suite on `main` the first time it ran, and eight more once `WebViewPool` and `SVGRenderer` were added to the list of production types it treats as building a page. That list is the boundary of both checks and is documented as such: a production type that builds a page but is not named there is invisible to them, and nothing can discover the omission automatically. Nothing is skipped, excluded or reordered, and the number of tests executed is unchanged.+- `Tools/check-test-results.sh` now tells you what to look for when it detects the cascade (T-2219). It used to point at `~/Library/Logs/DiagnosticReports/prism-*.ips` and stop there. Those reports are frequently never written — three consecutive reproductions on the development machine produced none — and they rotate away within days, which is how this ticket twice lost the only evidence it had. The message now names the stack signature that identifies this abort, so a report that does exist can be read correctly on the first attempt, and states that there is no in-process alternative to it.+ - Images beside a saved-out pasted document now appear as soon as it is saved (T-1784). Saving pasted markdown into a folder left every relative image in it — `![diagram](diagram.png)` and the like — showing the error placeholder, because the rendered document went on looking for images where an unsaved paste keeps them, which is nowhere: it had no folder to read from, so each one was refused before it was read. Closing and reopening the document was the only way to see them. Saving deliberately changes the document in place, keeping its position, its notes and everything already worked out about it, and that is exactly why nothing told the rendered page where the document now lives. It is told now, and the page re-fetches its images at the same reading position, so the images simply appear where they were missing and nothing else about the document moves. - Opening a large remote document no longer stalls while the download is accumulated one byte at a time on the main actor (T-2260). The loader now receives the body in chunks through a `URLSession` delegate, appending each chunk as it arrives and still refusing anything over 10 MB the moment the running total crosses the limit. Measured on this project's build, accumulating a 10 MB body drops from roughly 133 seconds to well under a second — about 400 times faster — so a large document opens promptly instead of holding the interface still. - A remote document opened from a URL is no longer left downloading indefinitely against a server that trickles the body slowly enough to dodge the 30-second timeout (T-2138). The timeout applied only to network inactivity, so a byte sent just before each interval elapsed kept the load open with no end-to-end bound; the whole download is now also bounded by an explicit 30-second deadline covering redirects and streaming together. Implementing that deadline also surfaced, and fixed, a separate, pre-existing problem: accumulating the downloaded body ran on the main thread, where it is roughly 150 times slower. Measured on this project's own build, accumulating a 10 MB body takes 0.88 seconds off the main thread and 133 seconds on it, which could freeze the interface for well over a minute while opening a large document. That accumulation was moved off the main thread here, and has since been replaced outright by the chunked read described under T-2260 below; the final UTF-8 decode of the (at most 10 MB) result still runs on the main thread afterwards, at roughly 10 milliseconds, which stays negligible. A file that really is over 10 MB is refused for its size, with the message that says so, rather than as a network timeout — with one trade-off: the new end-to-end deadline applies regardless of why a download is slow, so an honest, otherwise-successful download that used to take longer than 30 seconds to complete now fails with a timeout instead of eventually finishing.
docs/agent-notes/development-tooling.md Modified +85 / -0
diff --git a/docs/agent-notes/development-tooling.md b/docs/agent-notes/development-tooling.mdindex db03c9da..2270830c 100644--- a/docs/agent-notes/development-tooling.md+++ b/docs/agent-notes/development-tooling.md@@ -174,6 +174,91 @@ Four things about the guard itself, all learned by it failing on this repo:   widening the class would buy an unreachable gap at the price of   over-extensions on ordinary lines. Revisit if one is ever introduced. +## The OTHER host abort: an ObjC exception on a Swift job (T-2219, third pass)++The synchronous-construction rule above is one of two mechanisms. The second+survived PR #380 and reopened T-2219 twice. Do not re-derive it; it cost three+passes.++`prism-2026-08-30-213219.ips`, main thread, read bottom-up:++    swift::runJobInEstablishedExecutorContext+    WebKit                          (+533980, stripped in the shared cache)+    +[NSException raise:format:]    (CoreFoundation)+    objc_exception_throw+    __cxa_throw / _Unwind_RaiseException+    _swift_exceptionPersonality     <-- the frame that explains everything+    swift::fatalError+    abort++WebKit raises an Objective-C exception on a Swift async job; the exception+unwinds into a Swift frame; Swift's personality routine refuses and aborts.++**Do not try to record it in-process.** The abort happens inside the throw, so it+never reaches `_objc_terminate` and `NSSetUncaughtExceptionHandler` is never+called. This was tested, not assumed: a recorder was written, armed from every+live-WebKit entry point, the abort reproduced, and the log was empty. The+recorder was deleted. The `.ips` is the only witness, it is often never written+(three consecutive reproductions produced none), and it rotates within days —+`check-test-results.sh` now prints the signature so a report that DOES exist is+read correctly first time.++**Do not trust the named test.** The job that dies is whichever one is resumed at+that instant. Two reproductions blamed `URLEncodingCorpusTests`, which touches no+WebKit.++**The path this repo owns** is a `WKURLSchemeTask` given a response, data, a+finish or a failure after WebKit stopped it — routine during navigation-away, a+superseded load, or a page release. `PrismDocSchemeHandler.reply(for:)` produced+from an unstructured `Task` into an `.unbounded` `AsyncThrowingStream`, so+production and consumption were independent and every un-made yield raced the+stop; three of its four production points had no cancellation check at all.+Everything now goes through `SchemeTaskSink`, which stops producing once the task+is cancelled, failures included. Pinned by a SOURCE contract+(`PrismDocSchemeTaskSinkContractTests`) because a behavioural test of this race+would be the outage: the losing side aborts the process running it.++**Do not read the sink as a closed door.** Its cancellation comes from+`continuation.onTermination` on an unstructured task, so `Task.isCancelled`+becomes true only after the continuation is already inert — the guard agrees with+the stop, it cannot pre-empt it. What it buys is that a producer stops feeding a+buffer nobody is draining. Bounding the buffer is not the missing piece:+`AsyncStream` has no back-pressure at any policy, so `.bufferingNewest`/+`.bufferingOldest` would DROP a `.response` or a `.data` and serve a truncated+document. Real back-pressure would mean re-plumbing the handler onto a rendezvous+channel.++**The condition, and why "it peaks at 226" is not the answer.** `launchd` shows+the dead host started 230 `com.apple.WebKit.*` services with 226 alive+simultaneously 11 ms before its last log line, only 4 ever reclaimed (~206+concurrent live `WebPage`s; soft `maxfiles` is 256, which is a red herring —+every CLEAN run peaks in the same place: 226, 228, 223, 231, 228, 227). So the+working set is the condition the race needs, not the trigger, which is exactly+why the abort is load-dependent and unreproducible in isolation. It comes from+swift-testing's uncapped in-process concurrency (`-parallel-testing-worker-count+1` bounds host PROCESSES) meeting the slowest tests in the target.+`@Suite(.liveWebKit)` caps it at 32; measured 226 -> 59, with reclamation+restored.++**The guard's seed list is the boundary, and it goes blind quietly.**+`PRODUCTION_WEBKIT_TYPES` in `Tools/check-webkit-test-isolation.py` is an+explicit list of production types that build a page; reachability is seeded from+it, never followed through the target. A type that is missing is invisible to+BOTH rules — the synchronous-construction one and the budget one — so every test+driving it scans clean. `verify_seeds` only keeps listed entries honest; it+cannot find an absent one. This has already cost a pass: `WebViewPool` and+`SVGRenderer` were missing, so `SVGRendererTests.acceptSourceAtLimit` drove a+real pooled `WKWebView` unchecked and uncapped. Adding them exposed eight more+suites and three synchronous bodies. Add a new WebKit-building production type to+that list in the change that introduces it.++**Reproduction, if you need one:** roughly 1 run in 5 of `make test-quick` on a+machine also running other suites. Six consecutive runs on an idle machine were+clean. Budget your time accordingly, and read the unified log rather than the+result bundle — `log show --predicate 'process == "launchd"'` filtered to+`pid/<host>/com.apple.WebKit` gives the process census, and+`processID == <host>` gives WebKit's own trace of the final millisecond.+ ## Test-suite gotchas found while clearing the T-1541/T-1983 backlog  - **`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 an `async` test in a `@MainActor` suite** (an ACTOR-ISOLATED async function hops on entry as ABI — `async` on its own hops nowhere); the suite additionally wraps construction in `MainActor.run`, which is runtime-enforced.

Things to double-check

CHANGELOG conflict on rebase.

The branch is one commit behind origin/main (b249ae37), and CHANGELOG.md is the only file both touch — this branch edits the Changed section and prepends to Fixed, main appended to Fixed. Expect a small conflict on rebase. Unrelated, but visible while resolving it: origin/main currently carries three near-duplicate T-2231 entries under Fixed (lines 27-29), which looks like a bad merge on that PR and is worth cleaning up separately.

The sink's own doc comment lives outside the region the contract test checks.

productionOnlyThroughTheSink removes [sinkStart, sinkEnd) where sinkStart is the range of "private struct SchemeTaskSink". The ~60-line doc comment ABOVE that line is therefore in the checked "outside" text. It is clean today (I confirmed only three continuation. occurrences match the tokens, all inside the struct), but any future prose in that comment spelling continuation.yield( or continuation.finish( fails the build.

verify_budget_exists is a string grep, not a runtime proof.

It greps the budget file for AsyncSemaphore and semaphore.acquire. That catches deletion, but not a semantic change in swift-testing's SuiteTrait where Self: TestScoping scope-provider defaults, which would make the cap silently inert while the guard still prints "every live-WebKit suite is capped". One cheap Swift test driving LiveWebKitTrait().provideScope concurrently past the limit and asserting observed concurrency never exceeds LiveWebKitBudget.limit would close it — the same “green run that verified nothing” shape the rest of this tooling exists to prevent.

28% of permit-takers never touch WebKit.

145 of the 516 tests charged against the budget do not reach a WebKit constructor, because several annotated suites are mixed. Harmless for correctness, and suite-level annotation is the right maintainability call, but it is queueing pressure that buys nothing and is worth remembering if the cap ever needs tuning.