prism branch T-2146/bugfix-ci-live-webkit-test-failures commits 2 + review fixes files 7 touched lines +673 / -8 CI billing-blocked — fix unproven on a runner

Pre-push review: T-2146 — Serialise the CI sweep's test workers

PR #361 — CI-tooling fix for the 151-failure first honest sweep (run 31366718394). One GitHub review round was already complete before this pre-push review; this round found and fixed further issues, including one real bug introduced while hardening the review's own suggestion (a pipefail/SIGPIPE trap). The fix is unproven on a real runner: GitHub Actions is billing-blocked.

At a glance

  • Primary fix: -parallel-testing-worker-count 1 on both of test-locales' test invocations — the only CI-run target was the one place the project's own serialisation conclusion was never applied.
  • Regression guard: new check [8] pairs the pin with each test-running invocation (aggregate counting fails open), and carries the reviewer-found fail-open as a permanent in-script negative case.
  • Perf budgets: 20x ciPerformanceMultiplier following the convention in three sibling suites; T-1986's XCTExpectFailure made non-strict with an issueMatcher scoped to its one assertion.
  • This review round fixed a stale Regression Test section in the bugfix report, scoped the negative-case mutation, added a mutation-applied pre-check — and the pre-check surfaced a genuine shell trap: under set -o pipefail, diff | grep -q fails on success because -q kills diff with SIGPIPE at the first match.
  • Honesty preserved: no suite is excluded from CI, and the report states the fix is an inference until a sweep runs (verified: the caveat is in the Status line and a bolded Validation block).

Verdict

Ready to push — unproven on CI

All review findings are fixed in the working tree or consciously skipped with reasons recorded below. Local validation is green: make verify-make-guards (including new check [8] and its negative case), shellcheck, make lint (0 violations), and xcodebuild build-for-testing (macOS) all pass. What cannot be validated is the thing the PR exists for: the app test suite was not executed locally (machine-wide _libsecinit_appsandbox hang, documented in the diff itself) and GitHub Actions is billing-blocked, so the serialisation fix has not been observed working on a real runner. The causal argument is strong — every non-perf failure in the analysed bundle is starvation-shaped — but the first sweep after billing is restored is the real test, and the report says so prominently.

Review findings

14 raised · 8 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

The project's automated test job on GitHub kept failing: 151 of 4,240 tests went red the first time the job ever really ran them. 143 of those were the same symptom — a web page inside a test waited 30 seconds to load and gave up.

The cause was not the tests. The test tool (xcodebuild) was allowed to decide how many copies of the app to run at once, and on GitHub's small cloud machine (3 CPU cores, 7 GB memory) it started several full copies simultaneously — each of which also spawns its own browser helper processes. The machine was so overloaded that page loads never got a turn. The fix is one flag, -parallel-testing-worker-count 1: run one copy at a time.

Why It Matters

This job is the project's only automated test signal; while red, every change landed without a safety net. The fix deliberately keeps all tests running — switching the flaky ones off would have made the job look healthy while quietly testing less.

Key Concepts

  • CI: a cloud machine that runs your tests on every change — a robot proofreader.
  • Test host: a copy of the app launched to run tests inside it. Here each copy is heavyweight: a whole app that opens real web views.
  • Resource starvation: too many processes competing for too few cores, so some never get scheduled — nine people sharing three chairs.
  • Regression guard: a script check that fails the build if someone later removes the fix by accident.

Changes Overview

  • Makefiletest-locales (the only target CI runs) gains the worker pin on both test invocations: the per-locale test-without-building loop and the prismUITests run. The other three test targets already had it.
  • Tools/Tests/test-make-guards.sh — new check [8]: expands each recipe with make -n, reassembles backslash-continued logical lines, splits at command separators, and requires the pin inside every fragment that invokes xcodebuild test/test-without-building. A permanent negative case mutates a scoped copy of the Makefile (pin moved onto build-for-testing) and asserts the check reports an unpinned run.
  • .github/workflows/localisation-tests.ymltimeout-minutes 90 → 150 (serialising raises wall-clock; the number bounds a hang, not the work).
  • RawSourceHighlightingPerformanceTests.swift — five wall-clock budgets adopt the existing 20x ciPerformanceMultiplier convention via budget(ms:); T-1986's expectation becomes non-strict with a scoped issueMatcher.
  • docs/agent-notes/development-tooling.md — documents the machine-wide _libsecinit_appsandbox hang and what remains verifiable despite it.
  • Bugfix report + this review's implementation.md.

Implementation Approach

The diagnosis came from the actual result bundle of run 31366718394, not the ticket summary. All 151 failures classify into starvation-shaped buckets (143 load timeouts, 3 SIGSEGVs, 2 ordering/race flakes) plus 3 genuinely different ones (two perf budgets missed by 3–7%, one inverted XCTExpectFailure). Hence two prongs: serialise the workers, scale the wall-clock budgets — and never skip a suite.

Trade-offs

  • Serial vs parallel: wall-clock cost accepted over a red-or-dishonest job; measured headroom says a full sweep is tens of minutes against the 150-minute cap.
  • 20x scaling: keeps the requirement figure legible (budget(ms: 300) for Req 9.2) at the cost of only catching catastrophic regressions; relative-comparison tests still catch drift.
  • Non-strict expectation: stops CI failing when T-1986's bug does not reproduce, at the stated cost of swallowing any same-assertion failure magnitude and losing the "looks fixed" signal.

Technical Deep Dive

prism.xctestplan marks prismTests "parallelizable": true, so absent an explicit worker count xcodebuild sizes the pool from the machine and runs multiple test hosts — each a full WindowGroup app whose live WebKit suites open real WebPages with their own WebContent/GPU XPC helpers. On a 3-core/7 GB runner the process population exceeds schedulable capacity: 143 × .loadTimedOut, with the SIGSEGVs and ordering/race failures as the same starvation in different clothes. Arithmetic corroborates overlap, not summation: the whole sweep step measured 8m25s (GitHub API job timing) while 143 × 30s of timeouts alone would be ~71 minutes.

pin_audit handles the two lexical hazards of auditing a make -n expansion: backslash continuations (awk buffers until a line without a trailing \) and compound commands (splitting on [;|&] so a pin in one fragment cannot vouch for a sibling — $(PIPE_PRETTY)'s pipe splits the xcodebuild fragment after the pin, which is load-bearing and now guarded). The action-detection regex is action-first by documented assumption; an invocation written flags-first would fail closed in a single-run target.

The T-1986 inversion: a strict XCTExpectFailure asserts the defect reproduces on every machine — on the runner recolor genuinely was faster, so XCTest failed the test with "Expected failure … but none recorded". nonStrict() removes that direction; the issueMatcher (type + message, tied to the assertion via a shared constant after this review) keeps the expectation from swallowing crashes or later assertions.

Architecture Impact

No production code changes. The one structural commitment: the CI sweep is now defined serial, and re-parallelising must get past check [8] — intentional friction. The agent-note redefines what local validation means on this machine: guards/lint/compile are the verifiable surface; suite execution is not.

Potential Issues

  • Unproven on a real runner (billing block). If timeouts persist at worker count 1, runner WebKit capability becomes the live hypothesis and a named exclusion becomes defensible.
  • Duration: measured 8m25s for build + one full (red) configuration bounds the risk well under 150 minutes, but a green run does more work than a timeout-stalled one; a sweep timeout should be read as duration first, hang second.
  • Sensitivity swallowed: 20x budgets only catch order-of-magnitude regressions; the non-strict expectation cannot distinguish 5% from 5x slower on that one assertion.
  • pin_audit is lexical: hiding the xcodebuild invocation behind a variable or helper script would evade the audit, failing closed via the zero-runs arm.

Important changes — detailed

Makefile: pin test-locales to one test worker

Makefile

Why it matters. The primary fix. test-locales is the only target CI runs, and the only test target that never pinned the worker count — the project's own conclusion about this suite was applied everywhere except where it mattered.

What to look at. Makefile:299 and Makefile:317 (both xcodebuild test-without-building invocations)

Takeaway. When a conclusion is encoded as a per-target flag rather than a shared definition, every new target re-decides it silently. The comment block above the target records the measurement so the flag cannot be read as cargo cult.
Rationale. Serialising is what the evidence supports: every non-perf failure class in the analysed bundle is starvation-shaped, and the three sibling targets already pin for shared-global-state reasons.

test-make-guards.sh: check [8] pairs the pin per invocation

Tools/Tests/test-make-guards.sh

Why it matters. The regression guard. The first version compared aggregate counts, which fails open: a pin moved off a real test run onto build-for-testing keeps totals equal while that run goes unbounded. The GitHub review round caught it; the reproduction is kept as a permanent in-script negative case.

What to look at. pin_audit() plus the scoped negative case, Tools/Tests/test-make-guards.sh check [8]

Takeaway. A guard that counts occurrences instead of pairing them with their site is a totals check, not an invariant check. Keep the reviewer's counter-example as an executable negative case so the fail-open cannot return.
Rationale. Reassembling make's logical lines and splitting at command separators makes the audit see exactly what the shell sees, so the pin must sit in the same fragment as the invocation it governs.

test-make-guards.sh: mutation pre-check exposes a pipefail/SIGPIPE trap

Tools/Tests/test-make-guards.sh

Why it matters. Added in this review round: if the negative-case mutation ever stops landing (flag reorder), the check must say 'mutation did not apply' rather than mis-diagnosing the audit. The first version of the pre-check used diff | grep -q — which under this script's set -o pipefail fails on success, because -q exits at the first match and kills diff with SIGPIPE.

What to look at. the removed_pins pre-check in the negative case, Tools/Tests/test-make-guards.sh

Takeaway. Under pipefail, any producer | grep -q pipeline can report failure precisely when the match exists. Use grep -c (consumes all input) or capture the producer's output first.
Rationale. Found empirically: the pre-check passed when replayed in a shell without pipefail and failed inside the script, which is the exact signature of the trap.

RawSourceHighlightingPerformanceTests: 20x budgets + scoped non-strict expectation

prismTests/RawSourceHighlightingPerformanceTests.swift

Why it matters. The three non-starvation CI failures: two wall-clock budgets missed by 3-7% on shared hardware, and T-1986's strict XCTExpectFailure failing because the bug did NOT reproduce on the runner — reporting the problem being fixed as a failure.

What to look at. ciPerformanceMultiplier, budget(ms:), and the recolorAssertionMessage/issueMatcher block in testRecolorPerformance5000Lines

Takeaway. A strict expected-failure asserts the bug reproduces on every machine — a hardware-dependent defect needs nonStrict() plus an issueMatcher so only that one assertion is expected, and the matcher must share a constant with the assertion message so they cannot desynchronise.
Rationale. Follows the convention already in three sibling suites (20x named constant) so the requirement figure stays legible; the comment states honestly what sensitivity is being given up.

localisation-tests.yml: timeout bounds a hang, not the work

.github/workflows/localisation-tests.yml

Why it matters. Serialising raises wall-clock, so 90 minutes could now be tripped by legitimate work. The comment pins the number's single job: a sweep that hits 150 minutes is a hang to investigate, never 'the tests need a bigger number'. A second comment records that nothing is excluded from the sweep.

What to look at. .github/workflows/localisation-tests.yml timeout-minutes and the sweep-step comment

Takeaway. Document what a timeout means before someone tunes it: a bound on hangs sits well clear of expected duration; a bound on work invites ratcheting.
Rationale. Measured headroom: the analysed run's whole sweep step took 8m25s including build and one full timeout-stalled configuration, so 150 minutes is a hang detector, not a squeeze.

report.md + agent-notes: the fix is an inference until a sweep runs

specs/bugfixes/ci-live-webkit-test-failures/report.md

Why it matters. GitHub Actions is billing-blocked and the developer machine cannot launch sandboxed prism.app at all (_libsecinit_appsandbox hang, machine-wide). The report states in its Status line and Validation section that the fix has not been observed working on CI, and the agent-note stops the next session re-deriving the hang.

What to look at. report.md Validation section; docs/agent-notes/development-tooling.md new T-2146 section

Takeaway. When validation is impossible, the honest artifact says what was verified (guards, lint, compile), what was not (the suite on a runner), and what evidence would flip the conclusion (timeouts persisting at worker count 1).
Rationale. The ticket's own history (T-1983: a green job that ran nothing) is the cautionary tale the report is written against.

Key decisions

No suite is excluded from CI.

The tempting fix — a -skip-testing: list for the live WebKit suites — would have recreated T-1983 in softer form: a job that looks like it tests the renderer and does not. The bundle evidence says the tests were starved, not incapable; the workflow comment records the reasoning at the point where a future maintainer would add the exclusion.

Serialise all workers rather than pin selectively.

All three developer-run targets already pin to 1 for shared-global-state reasons (MockURLProtocol.handler, NSWindow notifications), so per-suite selective parallelism was never on the table for this codebase — the fix applies the existing project conclusion to the one target that missed it.

20x named multiplier over nudged budgets.

Widening 60 → 70ms erases the requirement figure and needs redoing on the next runner generation. The named-constant convention (three sibling suites) keeps budget(ms: 300) legible for Req 9.2 with the allowance visible beside it. Cost stated in the comment: catches catastrophic regressions, not modest ones.

T-1986 expectation non-strict, scoped by issueMatcher.

Strict fails in both directions of a hardware-dependent property — on the runner the bug did not reproduce and XCTest failed the test for it. The matcher (type + message) keeps crashes and later assertions failing the suite; a shared constant (this review) ties the matcher to the assertion message.

Per-invocation pairing over aggregate counting in check [8].

The counting version fails open when a pin migrates to build-for-testing. The reviewer's counter-example is kept as a permanent executable negative case, now scoped to the test-locales recipe so it cannot collaterally strip test-ui's pin, and guarded by a mutation-applied pre-check.

grep -c over grep -q under pipefail.

This review round: diff | grep -q under set -o pipefail fails on success — -q exits at the first match, killing diff with SIGPIPE. grep -c consumes all input; diff's exit-status-1-for-differing is discarded by the command substitution.

Duplication in the guard script and the perf suites is accepted, not fixed here.

Check [8]'s awk reassembly duplicates check [7]'s bash version, and ciPerformanceMultiplier/budget(ms:) is now the fourth per-suite copy. Both flagged by review agents; both left as follow-ups — a shared helper touches unrelated passing checks/suites, and the quality agent verified empirically that check [8] does not need check [7]'s --no-print-directory chatter hardening (make chatter cannot match the xcodebuild regex).

Timeout sits well clear of expected duration.

150 minutes against a measured 8m25s for build + one full configuration (realistic full serial sweep ~40–80 minutes): the number's only job is bounding a hang, and the comment says a trip should be investigated as one.

Uniform pin over selective parallelism structures.

Efficiency lane confirmed the rejected-alternatives reasoning: splitting live-WebKit suites from a parallel remainder would double the invocations and complicate the guard arithmetic; -maximum-parallel-testing-workers 2 would reintroduce the contention on a 3-core runner. The uniform pin is the right structure.

Review findings

SeverityAreaFindingResolution
majorreport.md — Regression Test sectionDescribed the superseded aggregate-counting version of check [8] and quoted a failure message ('2 test run(s) but only 0 pinned') that no longer exists; never mentioned the negative case. The second commit rewrote the check but not the report. (Raised independently by two agents.)Section rewritten around the per-invocation pairing semantics, the current failure message, and the permanent in-script negative case.
minorreport.md — Changes madeThe change list named four files; the diff touches five — docs/agent-notes/development-tooling.md was only mentioned in passing.Fifth bullet added.
minorreport.md vs Makefile/check [8] commentsTwo unreconciled denominators for the same 143 timeouts: the report says 151 failures, the Makefile and guard comments say '143 of 189 messages'; 189 appears nowhere in the report.One sentence added to the report: 189 counts failure messages, 151 counts failing tests (a failing test can record more than one message); the table classifies by test.
minorRawSourceHighlightingPerformanceTests.swift — issueMatcherThe matcher string duplicated the assertion message as a second literal; a rename of either desynchronises them (fails closed but mysteriously). Also, recolorExpectation named an Options object, not an expectation.Shared recolorAssertionMessage constant feeds both matcher and assertion; variable renamed recolorExpectationOptions. Recompiled clean.
minortest-make-guards.sh — negative-case mutation scopeThe awk mutation re-armed on every -only-testing:prismUITests line, so it also stripped test-ui's pin in the mutated copy — harmless today (only test-locales is expanded) but contradicting its own comment and a confound if the case ever expands another target.Mutation scoped to the test-locales recipe: a column-0 target line opens/closes scope for both the drop and the build-for-testing insertion.
minortest-make-guards.sh — negative-case diagnosisIf a flag reorder means the pin no longer follows the prismUITests marker, the mutation drops nothing and the check would blame the audit ('counting, not pairing') instead of the mutation. Fails closed, but sends the debugger to the wrong place.Mutation-applied pre-check added: the diff must show a removed pin line before the audit's verdict is trusted. Fixing this surfaced the pipefail/SIGPIPE trap (see decisions) — the first pre-check version failed on success.
minortest-make-guards.sh — action-first regex assumptionThe detection regex requires the action word directly after xcodebuild; a flags-first invocation (xcodebuild -project X test) would escape the audit in a multi-run target (fail-open for that invocation), though it fails closed in a single-run target.Assumption documented at the definition with the fail-open/fail-closed analysis and an instruction to keep the action-first style.
minortest-make-guards.sh — duplicate recipe reassembly (code reuse)pin_audit's awk duplicates check [7]'s bash logical-line reassembly and omits its --no-print-directory chatter hardening on the make -n calls.Skipped: verified empirically that make chatter lines cannot match the xcodebuild regex, so check [8] does not need the hardening; a shared reassemble helper would touch a passing check for no behavioural gain — follow-up material.
minorRawSourceHighlightingPerformanceTests.swift — multiplier copy (code reuse)Fourth per-suite copy of ciPerformanceMultiplier and second verbatim budget(ms:) copy.Skipped: matches the established per-suite convention (three sibling suites, same shape); extracting a shared constant across test files is a follow-up, not a defect in this PR.
nittest-make-guards.sh — separator splittinggsub(/[;|&]/) splits inside quotes and multi-char operators (&&, 2>&1).Skipped: all current recipe content traced — no quoted separators exist and every possible mis-split is fail-closed (splitting can only detach a pin, never merge fragments).
nitRawSourceHighlightingPerformanceTests.swift — budget(ms:)Int64(ms * multiplier) truncates fractional milliseconds.Skipped: all current inputs are exact multiples, and the helper is copied verbatim from the sibling suites — consistency wins.
nitassertion message styleThe '(x20.0 allowance)' suffix in the scaled assertion messages diverges cosmetically from the precedent suites' messages.Skipped: the suffix aids failure diagnosis (states the allowance at the point of failure) and harmonising it would touch sibling suites out of scope.
minorreport.md — duration risk paragraph (efficiency)The 'may approach the 150-minute cap' paragraph understated how well the measured data bounds the duration risk: run 31366718394's sweep step took 8m25s total — build-for-testing 5m22s, the whole en (base) test phase ~3 minutes including all 143 overlapping 30s timeouts.Paragraph rewritten with the measured split; every figure re-verified directly against the GitHub API step timing and the run log timestamps before citing (build 07:39:13-07:44:35, test 07:44:35-07:47:37).
nittest-make-guards.sh — process spawns (efficiency)Check [8] spawns ~150 short-lived grep/awk processes across the four audits plus the negative case.Skipped: measured under half a second total (54ms for all four make -n expansions); matches the sibling checks' style in a dev/CI-side script.

Per-file diffs

Click to expand.

Makefile Modified +16 / -0
diff --git a/Makefile b/Makefileindex d0cb91e..6fa22ce 100644--- a/Makefile+++ b/Makefile@@ -253,6 +253,20 @@ test-ui: # green does (run 31366718394: "en (base)" failed and en-AU/en-GB/en-US were # never tried). So each guard failure is recorded in $failed and the target # fails once, at the end, after every configuration has run and reported.+#+# Every run below pins -parallel-testing-worker-count 1, for the reason spelled+# out on test-quick and now measured on CI (T-2146). The test plan marks+# prismTests parallelizable, so without the switch xcodebuild sizes the worker+# pool from the machine and runs several test HOSTS concurrently. A host here is+# not a lightweight process: it is the whole app, and the live WebKit suites open+# real WebPages, each spawning WebContent and GPU helper processes of its own. On+# a developer Mac that races (shared global state — MockURLProtocol.handler,+# NSWindow notifications). On a 3-core, 7 GB GitHub runner it starves: the first+# honest sweep produced 143 `.loadTimedOut` failures out of 189 messages, waiting+# 30 s each for loads that were never going to be scheduled, plus 3 SIGSEGVs.+# test-quick, test and test-ui had pinned it from the start; this target, the only+# one CI runs, was the one place the project's own conclusion was not applied.+# Tools/Tests/test-make-guards.sh check [8] keeps it that way. .PHONY: test-locales test-locales: 	$(STRICT) xcodebuild build-for-testing \@@ -282,6 +296,7 @@ test-locales: 			-testPlan prism \ 			-only-test-configuration "$$cfg" \ 			-skip-testing:prismUITests \+			-parallel-testing-worker-count 1 \ 			$(PIPE_PRETTY) || true; \ 		Tools/check-test-results.sh "$$bundle" "test-locales: $$cfg" \ 			|| failed="$$failed [$$cfg]"; \@@ -299,6 +314,7 @@ test-locales: 		-testPlan prism \ 		-only-test-configuration "en (base)" \ 		-only-testing:prismUITests \+		-parallel-testing-worker-count 1 \ 		$(PIPE_PRETTY) || true; \ 	Tools/check-test-results.sh $(RESULT_BUNDLE_LOCALES_UI) "test-locales: prismUITests" \ 		|| failed="$$failed [prismUITests]"; \
Tools/Tests/test-make-guards.sh Modified +110 / -0
diff --git a/Tools/Tests/test-make-guards.sh b/Tools/Tests/test-make-guards.shindex 3dd049e..cadfac0 100755--- a/Tools/Tests/test-make-guards.sh+++ b/Tools/Tests/test-make-guards.sh@@ -338,6 +338,113 @@ fi rm -rf "$SANDBOX" echo +# --- 8. Every test run is serialised to one worker ---------------------------+# T-2146. The test plan marks prismTests `parallelizable`, so without an explicit+# -parallel-testing-worker-count xcodebuild picks a worker count from the machine+# and runs several test HOSTS at once. Each host is a full copy of the app: a+# WindowGroup app that opens WKWebViews, whose WebContent and GPU helper processes+# are separate processes again. On a developer Mac that merely races; on a+# 3-core / 7 GB GitHub runner it starves, and the live WebKit suites time out+# waiting 30 s for a WebPage load that never gets scheduled (run 31366718394:+# 143 of 189 failure messages were .loadTimedOut, plus 3 SIGSEGVs).+#+# test-quick, test and test-ui all pin the count to 1 and say why. test-locales —+# the only target CI runs — was the one that forgot, so the project's own+# knowledge that this suite cannot be run in parallel was applied everywhere+# except the place it mattered. Pin it here so that cannot silently regress.+echo "[8] every test run is serialised to one worker (T-2146)"++# The pin is paired with EACH test-running invocation, never counted in+# aggregate. The first version of this check compared totals ("N test runs, N+# pins anywhere in the recipe"), which fails open: moving the pin off a real+# test run and onto the build-for-testing step — which the run-counting regex+# rightly excludes, since it runs no tests — keeps the totals equal while that+# test run goes back to unbounded workers. So: reassemble the recipe's logical+# lines (a trailing backslash continues onto the next, exactly as make hands+# them to the shell), split each at command separators (`;`, `|`, `&`), and+# require the switch INSIDE every fragment that invokes `xcodebuild test` or+# `test-without-building`. Prints "<runs> <unpinned>" for the caller.+#+# The detection regex assumes the action word directly follows `xcodebuild`,+# which is how every recipe in this Makefile writes it. xcodebuild also accepts+# flags before the action (`xcodebuild -project X test`); an invocation written+# that way would not be counted as a test run — in a single-run target that+# fails closed ("expands to no test-running invocation"), but in a multi-run+# target it would escape the audit. Keep the action-first style.+pin_audit() {+    local recipe=$1 runs=0 unpinned=0 cmd+    while IFS= read -r cmd; do+        printf '%s' "$cmd" \+            | grep -qE '(^|[[:space:]])xcodebuild[[:space:]]+(test|test-without-building)([[:space:]]|$)' \+            || continue+        runs=$((runs + 1))+        printf '%s' "$cmd" \+            | grep -qE '\-parallel-testing-worker-count[[:space:]]+1([[:space:]]|$)' \+            || unpinned=$((unpinned + 1))+    done < <(printf '%s\n' "$recipe" | awk '+        { if (sub(/\\$/, "")) { buf = buf $0 " "; next }+          line = buf $0; buf = ""+          gsub(/[;|&]/, "\n", line)+          print line }+        END { if (buf != "") { gsub(/[;|&]/, "\n", buf); print buf } }')+    printf '%s %s\n' "$runs" "$unpinned"+}++for target in "${TEST_TARGETS[@]}"; do+    recipe=$(make -n "$target" 2>/dev/null)+    if [ -z "$recipe" ]; then+        fail "$target: could not expand the recipe with 'make -n'"+        continue+    fi+    read -r runs unpinned <<< "$(pin_audit "$recipe")"+    if [ "${runs:-0}" -eq 0 ]; then+        fail "$target: expands to no test-running xcodebuild invocation"+    elif [ "${unpinned:-0}" -eq 0 ]; then+        pass "$target ($runs test run(s), each pinned to one worker)"+    else+        fail "$target: $unpinned of $runs test run(s) lack their own -parallel-testing-worker-count 1 — they run unbounded parallel test hosts (T-2146)"+    fi+done++# Negative case: the fail-open the aggregate version missed, replayed against a+# mutated copy of the real Makefile. Drop the pin from test-locales'+# prismUITests invocation and put one on its build-for-testing step instead —+# the totals still balance, but the prismUITests run is genuinely unbounded,+# and the pairing above must say so. The mutation is scoped to the+# test-locales recipe (a target line at column 0 opens/closes scope) so it+# cannot also strip test-ui's pin, which matches the same prismUITests marker.+SANDBOX8=$(mktemp -d)+awk '+    /^[^\t]/ { scope = /^test-locales:/ }+    scope && dropnext && /-parallel-testing-worker-count 1/ { dropnext = 0; next }+    { dropnext = /-only-testing:prismUITests/ }+    { print }+    scope && /xcodebuild build-for-testing/ { printf "\t\t-parallel-testing-worker-count 1 \\\n" }+' Makefile > "$SANDBOX8/Makefile"+# Guard the mutation itself: if a flag reorder means no pin line was removed,+# the moved-pin scenario was never created and the audit below would be+# checking the real recipe — report that, not a false verdict on the pairing.+# grep -c, not grep -q: this script runs under pipefail, and -q exits on the+# first match, which kills diff with SIGPIPE and fails the pipeline even when+# the pin removal is present. -c reads all input, so only the count matters+# (diff's exit status 1 for "files differ" is discarded by the assignment).+removed_pins=$(diff Makefile "$SANDBOX8/Makefile" | grep -c '^<.*-parallel-testing-worker-count 1')+if [ "${removed_pins:-0}" -eq 0 ]; then+    fail "negative case: the mutation removed no pin (did a flag reorder move it away from -only-testing:prismUITests?) — fix the mutation before trusting this check"+else+    mutated=$(make -n -f "$SANDBOX8/Makefile" test-locales 2>/dev/null)+    read -r m_runs m_unpinned <<< "$(pin_audit "$mutated")"+    if [ "${m_runs:-0}" -eq 0 ]; then+        fail "negative case: could not expand the mutated test-locales recipe"+    elif [ "${m_unpinned:-0}" -ge 1 ]; then+        pass "negative case: a pin moved onto build-for-testing is still reported unpinned ($m_unpinned of $m_runs runs)"+    else+        fail "negative case: moving the prismUITests pin onto build-for-testing went undetected — the check is counting pins, not pairing them"+    fi+fi+rm -rf "$SANDBOX8"+echo+ if [ "$FAILURES" -gt 0 ]; then     echo "$FAILURES check(s) failed." >&2     exit 1
.github/workflows/localisation-tests.yml Modified +18 / -2
diff --git a/.github/workflows/localisation-tests.yml b/.github/workflows/localisation-tests.ymlindex d3a016a..446be6f 100644--- a/.github/workflows/localisation-tests.yml+++ b/.github/workflows/localisation-tests.yml@@ -35,7 +35,15 @@ jobs:     # This job used to finish in about 70 seconds because it ran nothing at all     # (T-1983). A real sweep is four full runs of the suite plus the UI tests, so     # it takes tens of minutes; the timeout is here to bound a hang, not the work.-    timeout-minutes: 90+    #+    # Raised from 90 with T-2146, which pinned every run in the sweep to a single+    # test worker. Serialising is the fix for the runner's 143 `.loadTimedOut`+    # failures, and it is also why the wall-clock goes up: the suite no longer+    # runs several test hosts side by side. Bounding a hang is still the only job+    # this number has, so it sits well clear of the expected duration rather than+    # tuned close to it — a sweep that trips this limit should be read as a hang+    # worth investigating, never as "the tests need a bigger number".+    timeout-minutes: 150     steps:       - uses: actions/checkout@v4 @@ -56,6 +64,16 @@ jobs:       # inside the target now writes a result bundle that       # Tools/check-test-results.sh reads, so a run that executes zero tests fails       # this job instead of quietly passing it.+      #+      # Nothing is excluded here. The first honest sweep went red with 151+      # failures (T-2146) and the temptation was to skip the live WebKit suites+      # to get a green tick, which would have recreated T-1983 in a softer form:+      # a job that looks like it tests the renderer and does not. The failures+      # were diagnosed as resource starvation instead — the sweep was running+      # unbounded parallel test HOSTS, each a full copy of a WKWebView-driving+      # app, on a 3-core runner — and fixed in the Makefile by serialising the+      # workers. So this job still runs every suite, live WebKit included, under+      # all four locale configurations.       - name: Run per-locale test sweep         run: make test-locales-adhoc 
prismTests/RawSourceHighlightingPerformanceTests.swift Modified +69 / -6
diff --git a/prismTests/RawSourceHighlightingPerformanceTests.swift b/prismTests/RawSourceHighlightingPerformanceTests.swiftindex 55602db..8ffdabe 100644--- a/prismTests/RawSourceHighlightingPerformanceTests.swift+++ b/prismTests/RawSourceHighlightingPerformanceTests.swift@@ -26,6 +26,34 @@ import XCTest @MainActor final class RawSourceHighlightingPerformanceTests: XCTestCase { +    /// Multiplier applied to every wall-clock budget in this suite.+    ///+    /// The requirement figures above (300ms for 5,000 lines, etc.) describe a+    /// Release build on an idle iPhone 12. These tests run a DEBUG build, next to+    /// the rest of the suite, on whatever machine is free — and since T-1983 that+    /// includes a shared GitHub runner. The first honest CI sweep failed here by+    /// margins of a few per cent (64.1ms against 60ms; 0.3086s against 0.3s) with+    /// no algorithmic change behind either: that is hardware, not a regression.+    ///+    /// This is the convention already used by `SearchPerformanceTests` and+    /// `InlineNotesExportPerformanceTests` with the same 20x constant, and by+    /// `NotesPerformanceTests` with 10x. Applying it here rather than nudging 60+    /// to 70 keeps the requirement figure visible in the source: the budget still+    /// reads `budget(ms: 300)` for Req 9.2, with the allowance named separately.+    ///+    /// The trade-off is explicit and is the same one those suites accept: at 20x+    /// these tests catch a catastrophic regression (an accidental quadratic, a+    /// dropped early-exit) and no longer catch a modest one.+    /// `testHighlightingScalability` and `testRecolorFasterThanParse`, which+    /// compare measurements against each other rather than against the clock, are+    /// what still catch gradual drift.+    private let ciPerformanceMultiplier: Double = 20.0++    /// A wall-clock budget scaled by ``ciPerformanceMultiplier``.+    private func budget(ms: Double) -> Duration {+        .milliseconds(Int64(ms * ciPerformanceMultiplier))+    }+     // MARK: - Test Theme Colors      /// Theme colors for performance testing.@@ -118,7 +146,7 @@ final class RawSourceHighlightingPerformanceTests: XCTestCase {         let elapsedMs = elapsed.components.seconds * 1000 + Int64(elapsed.components.attoseconds / 1_000_000_000_000_000)         print("5,000 lines with highlighting: \(elapsedMs)ms (target: <300ms)") -        XCTAssertLessThan(elapsed, .milliseconds(300), "Parsing 5,000 lines with highlighting should complete within 300ms, took \(elapsed)")+        XCTAssertLessThan(elapsed, budget(ms: 300), "Parsing 5,000 lines with highlighting should complete within 300ms (x\(ciPerformanceMultiplier) allowance), took \(elapsed)")     }      /// Benchmark test for highlighting performance across multiple iterations.@@ -144,7 +172,7 @@ final class RawSourceHighlightingPerformanceTests: XCTestCase {         print("1,000 lines with highlighting - 5 iterations average: \(String(format: "%.2f", avgMs))ms")          // Average should be under 60ms for 1,000 lines (scaled from 300ms for 5,000)-        XCTAssertLessThan(avgMs, 60.0, "Average parsing time should be under 60ms")+        XCTAssertLessThan(avgMs, 60.0 * ciPerformanceMultiplier, "Average parsing time should be under 60ms (x\(ciPerformanceMultiplier) allowance), was \(String(format: "%.2f", avgMs))ms")     }      // MARK: - Recolor Performance Tests (Req 9.10)@@ -170,7 +198,7 @@ final class RawSourceHighlightingPerformanceTests: XCTestCase {         let elapsedMs = elapsed.components.seconds * 1000 + Int64(elapsed.components.attoseconds / 1_000_000_000_000_000)         print("5,000 lines recolor: \(elapsedMs)ms (target: <500ms)") -        XCTAssertLessThan(elapsed, .milliseconds(500), "Recoloring 5,000 lines should complete within 500ms, took \(elapsed)")+        XCTAssertLessThan(elapsed, budget(ms: 500), "Recoloring 5,000 lines should complete within 500ms (x\(ciPerformanceMultiplier) allowance), took \(elapsed)")     }      /// Verifies that recolor is faster than full parse.@@ -228,10 +256,39 @@ final class RawSourceHighlightingPerformanceTests: XCTestCase {         // the existing runs instead of rebuilding. That is a design decision,         // so it is filed (T-1986) rather than settled by relaxing the         // assertion — which would delete the only evidence of the problem.+        //+        // Non-strict, because which way this comes out is hardware-dependent and+        // a strict expectation turns that into a failure in BOTH directions. On+        // the first honest CI sweep this test failed with "Expected failure ...+        // but none recorded" (T-2146): on the runner recolor WAS faster, so the+        // expectation went unfulfilled and XCTest failed the test for it. That+        // reports the problem being fixed as a failure, which is backwards.+        //+        // What non-strict costs, stated honestly: this assertion can no longer+        // distinguish "recolor 5% slower than parse" (the known T-1986 gap)+        // from "recolor 5x slower" — any failure of it, whatever the+        // magnitude, is swallowed as the expected one. And the reverse signal+        // is gone too: on a machine where recolor IS faster (T-1986 looks+        // fixed) the test now passes silently instead of demanding someone+        // close the ticket. The print above, which logs both timings on every+        // run, is the remaining breadcrumb for either drift. The issueMatcher+        // scopes the expectation to exactly this assertion, so anything else —+        // a crash, or an assertion added below later — still fails the suite.+        // One constant feeds both the matcher and the assertion so they cannot+        // desynchronise: if the message were renamed in one place only, the+        // matcher would stop matching and the failure would (safely but+        // mysteriously) fail the suite instead of being expected.+        let recolorAssertionMessage = "Recolor should be faster than initial parse"+        let recolorExpectationOptions = XCTExpectedFailure.Options.nonStrict()+        recolorExpectationOptions.issueMatcher = { issue in+            issue.type == .assertionFailure &&+                issue.compactDescription.contains(recolorAssertionMessage)+        }         XCTExpectFailure(-            "Recolor is not faster than a full parse; the ~10x design claim does not hold (T-1986)."+            "Recolor is not faster than a full parse; the ~10x design claim does not hold (T-1986).",+            options: recolorExpectationOptions         )-        XCTAssertLessThan(bestRecolor, bestParse, "Recolor should be faster than initial parse")+        XCTAssertLessThan(bestRecolor, bestParse, recolorAssertionMessage)     }      // MARK: - Memory Usage Tests (Req 9.6)@@ -322,7 +379,7 @@ final class RawSourceHighlightingPerformanceTests: XCTestCase {         print("15,000 lines with highlighting: \(elapsedMs)ms")          // Per design, ~900ms expected for 15,000 lines (300ms scaled from 5,000)-        XCTAssertLessThan(elapsed, .seconds(2), "Processing 15,000 lines should complete within 2 seconds")+        XCTAssertLessThan(elapsed, budget(ms: 2000), "Processing 15,000 lines should complete within 2 seconds (x\(ciPerformanceMultiplier) allowance), took \(elapsed)")     }      /// Tests that highlighting is disabled for documents over 15,000 lines.@@ -344,7 +401,7 @@ final class RawSourceHighlightingPerformanceTests: XCTestCase {         print("15,001 lines without highlighting: \(elapsedMs)ms")          // Without highlighting, should be faster-        XCTAssertLessThan(elapsed, .seconds(1), "Processing large doc without highlighting should be fast")+        XCTAssertLessThan(elapsed, budget(ms: 1000), "Processing large doc without highlighting should be fast (x\(ciPerformanceMultiplier) allowance), took \(elapsed)")     }      // MARK: - Scalability Tests
docs/agent-notes/development-tooling.md Modified +37 / -0
diff --git a/docs/agent-notes/development-tooling.md b/docs/agent-notes/development-tooling.mdindex 37464bf..7c00a08 100644--- a/docs/agent-notes/development-tooling.md+++ b/docs/agent-notes/development-tooling.md@@ -12,6 +12,43 @@ - **`XCTExpectFailure` breaks `check-test-results.sh`'s arithmetic check.** An expected failure is counted in neither `passedTests` nor `failedTests`, so the script prints `WARN: passed+failed+skipped != total`. That warning is benign when the difference equals the number of `XCTExpectFailure`s (currently 2, T-1985 and T-1986); it does not gate the build. - **One `MockURLProtocol` used to be shared by five suites** across `URLDocumentLoaderTests`, `ImageLoaderTests`, and `SVGSourceLoaderTests`. `.serialized` only orders tests *within* a suite, so the suites overwrote each other's static handler and served each other's payloads. Handlers are now registered per scope (`MockURLScope`), carried as a request header set from the session configuration. If you add a networked suite, give it its own scope rather than a global handler. +## "The test runner hung before establishing connection" on macOS (T-2146)++If every `xcodebuild test` / `test-without-building` against the **macOS**+destination dies after ~5m40s with `prism (NNNNN) encountered an error (The test+runner hung before establishing connection.)`, the test host is stuck in App+Sandbox initialisation, not in anything this repo owns. Confirm before+theorising: run the failing invocation in the background, find the host with+`pgrep -f 'Build/Products/Debug/prism.app/Contents/MacOS/prism'`, and+`sample <pid> 3 -mayDie`. The signature is a main thread parked entirely in++    libSystem_initializer -> _libsecinit_initializer -> _libsecinit_appsandbox+      -> _xpc_pipe_routine -> mach_msg2_trap++i.e. blocked on `secinitd` **before `main`**. Nothing in the app has run yet, so+no code change, entitlement, or signing mode affects it — measured identical for+a dev-signed and an ad-hoc-signed build of the same tree. `make test-quick` and+`xcodebuild test` fail the same way; so does launching `prism.app`'s binary+directly.++Things that are NOT the cause, each ruled out by measurement (don't redo these):+ad-hoc vs developer signing; `-parallel-testing-worker-count`; the Claude Code+bash sandbox; stale test hosts holding the `me.nore.ig.prism` LaunchServices+registration (worth clearing anyway — see below — but the hang survives it).++Related and worth clearing when you are in here, because it is a real leak rather+than a theory: `~/Library/Containers/me.nore.ig.prism/Data/` accumulates one+`{UUID}-{pid}-{hex}` directory per test-host launch and never removes them. On the+machine where T-2146 was investigated it held 37,073 of them, 7.1 GB, dating back+to March; the non-empty ones each contain a `.profraw` code-coverage dump. Every+sandbox init enumerates that directory.++Practical consequence for agents: **you may not be able to execute the Swift test+suite on macOS at all.** `xcodebuild build-for-testing` still works and still+type-checks the test target, `make lint` works, and `make verify-make-guards`+works, so Makefile/CI/compile-level changes remain verifiable — say so explicitly+rather than implying the suite passed.+ ## Convergence Harness (T-1513/T-1531)  - `Tools/convergence-probe.sh <fixture.md>` builds the macOS Debug app and scroll-drives a document end-to-end, watching a main-run-loop heartbeat. Verdicts: CONVERGES / NON-CONVERGENT / DEGRADED / TIMEOUT (see script header for thresholds and the pre-hoist calibration numbers).
specs/bugfixes/ci-live-webkit-test-failures/report.md Added +245 / -0
diff --git a/specs/bugfixes/ci-live-webkit-test-failures/report.md b/specs/bugfixes/ci-live-webkit-test-failures/report.mdnew file mode 100644index 0000000..86fbd30--- /dev/null+++ b/specs/bugfixes/ci-live-webkit-test-failures/report.md@@ -0,0 +1,246 @@+# Bugfix Report: Live WebKit Tests Fail on the CI Runner++**Date:** 2026-08-15+**Status:** Fixed (fix unverified on CI — see "Validation")+**Ticket:** T-2146+**Related:** T-1983 (the sweep repair that made this visible), T-1985, T-1986, T-1541++## Description of the Issue++The first CI run that ever actually executed this project's test suite+(run 31366718394, from T-1983 / PR #354) executed 4,240 tests on `macos-latest`+for the "en (base)" configuration and reported:++```+total=4240 passed=4051 failed=151 skipped=37+```++The failures were dominated by the live WebKit suites — the ones that drive a+real `WebPage` through `prismTests/WebRenderingSpikes/SpikeWebPageHarness.swift`.+The harness allows 30 seconds per load, so these were not marginal slowness.++**Impact:** the only test-executing CI job on the project is red, and has never+been green on real results. Because it is the sole test signal, every commit+since T-1983 lands without one.++## Investigation Summary++The result bundle for run 31366718394 was still within its 7-day retention and+was downloaded and parsed directly, so this analysis is against the real CI data+rather than the summary in the ticket. All 151 failures classify cleanly.+(Two denominators appear in this work and both are real: the bundle carries 189+failure *messages* for the 151 failing *tests* — a failing test can record more+than one message. The Makefile and check [8] comments quote "143 of 189+messages"; this table classifies by test.)++| Count | Failure | Character |++| Count | Failure | Character |+|-------|---------|-----------|+| 143 | `Caught error: .loadTimedOut` | live WebKit, 30s load timeout |+| 3 | `Test crashed with signal segv` | 2 live WebKit, 1 the 10MB streaming test |+| 1 | recovery reasons out of ORDER (`[.processTerminated, .reloadStalled, .reloadFailed]` vs `[.processTerminated, .reloadFailed, .reloadStalled]`) | async completion order |+| 1 | `viewModel.lines.first?.text -> "First document"` (expected `"Final content"`) | last-writer-wins race |+| 2 | 64.14ms vs 60ms; 0.3086s vs 0.3s | wall-clock budget, ~3-7% over |+| 1 | `Expected failure '...T-1986' but none recorded` | hardware-dependent expectation |++Two corrections to the ticket's account, both from the bundle:++- **T-1985 did not fail.** Its memory figure is computed from a formula, not a+  real reading, so its `XCTExpectFailure` held on CI exactly as it does locally.+  The only `XCTExpectFailure` that failed was T-1986's, and it failed *inverted*+  — see below.+- The 36 "Requires full app context with window" messages are `.disabled` traits+  (skips), which the ticket already noted.++### Hypothesis separation (the ticket's step 1)++The two candidate causes were the CI environment and the ad-hoc code signing+that PR #354 introduced. They were separated locally:++1. Built `build-for-testing` ad-hoc on a quiet developer machine using the+   Makefile's own `ADHOC_SIGNING` flags. **The build succeeds**, and the+   resulting `prism.app` carries the expected effective entitlements —+   `com.apple.security.app-sandbox`, `com.apple.security.network.client` (the+   one WKWebView needs on macOS), `get-task-allow`, and the testmanagerd+   mach-lookup exceptions. Nothing WebKit-relevant is missing.+2. Ran one live suite (`WebThemeStateSyncTests`) against that ad-hoc build, and+   the same suite against a normally dev-signed build of the same tree, as a+   control.++**Both signings behaved identically**, so the ad-hoc signature is not what+distinguishes a working run from a broken one. **Signing is exonerated.**++(The local runs did not produce a passing baseline either: on this machine every+sandboxed launch of `prism.app` — dev-signed and ad-hoc alike — hangs in+`_libsecinit_appsandbox`, waiting on `secinitd` before `main` is reached, so+`xcodebuild` reports "The test runner hung before establishing connection". That+is a machine-level App Sandbox pathology, not a property of the build, and it is+the same condition that made the ticket author's local isolation untrustworthy.+It is recorded in `docs/agent-notes/development-tooling.md` so the next session+recognises it instead of re-deriving it. It is unrelated to the CI symptom: on CI+the host connected fine and ran 4,240 tests.)++## Discovered Root Cause++**The sweep was running unbounded parallel test hosts on a 3-core runner.**++`prism.xctestplan` marks the `prismTests` target `"parallelizable": true`. Absent+an explicit `-parallel-testing-worker-count`, `xcodebuild` sizes the worker pool+from the machine and runs several test **hosts** concurrently. A host here is not+a lightweight process: it is the whole app, and the live WebKit suites open real+`WebPage`s, each spawning WebContent and GPU helper processes of its own.++`test-quick`, `test` and `test-ui` all pin the worker count to 1, and the+Makefile says why beside `test-quick`:++> Single locale config + worker count 1 to avoid cross-process races in shared+> global state (e.g. `MockURLProtocol.handler`, `NSWindow` notifications) that+> surface when xcodebuild runs the locale matrix in parallel workers.++`test-locales` — **the only target CI runs** — was the one place that did not.+Neither its per-locale loop nor its `prismUITests` run passed the switch. The+project's own conclusion about this suite was applied everywhere except where it+mattered.++**Defect type:** missing resource constraint in one build target; an+inconsistency between the CI target and every other test target.++**Why it occurred:** the switch was added to the three targets a developer runs+by hand, at a time when `test-locales` executed nothing at all (T-1983) and so+had no observable behaviour to correct. When T-1983 made the sweep really run,+it ran under the one configuration nobody had ever exercised.++**Why the symptom is what it is:** on a developer Mac, oversubscription merely+races. On a 3-core, 7 GB GitHub runner it starves, and every failure class in the+table above is what starvation looks like:++- 143 loads that never got scheduled inside 30s -> `.loadTimedOut`.+- 2 of the 3 SIGSEGVs are live WebKit tests; the third allocates a >10MB buffer,+  multiplied by however many hosts were resident.+- The recovery-reasons failure is an **ordering** difference between two async+  completions, not a wrong value.+- The `lines.first?.text` failure is a last-writer-wins cancellation test where+  the newer task lost the race.++The remaining three failures are *not* starvation and are handled separately:+two absolute wall-clock budgets, and one `XCTExpectFailure` that CI inverted.++## Resolution for the Issue++**Changes made:**++- `Makefile` — `test-locales`: added `-parallel-testing-worker-count 1` to both+  the per-locale `test-without-building` invocation and the `prismUITests` one,+  with a comment recording the measurement behind it. This is the primary fix.+- `.github/workflows/localisation-tests.yml` — `timeout-minutes` 90 -> 150,+  because serialising the workers raises the wall-clock; and a comment on the+  sweep step recording that **nothing is excluded** and why.+- `prismTests/RawSourceHighlightingPerformanceTests.swift` — adopted the+  project's existing `ciPerformanceMultiplier` convention (20x, as used by+  `SearchPerformanceTests` and `InlineNotesExportPerformanceTests`) for this+  suite's five wall-clock budgets, and made T-1986's `XCTExpectFailure`+  non-strict.+- `Tools/Tests/test-make-guards.sh` — new check [8], the regression test.+- `docs/agent-notes/development-tooling.md` — documents the machine-level+  `_libsecinit_appsandbox` hang found during investigation (see "Validation"),+  so the next session recognises it instead of re-deriving it.++**Approach rationale:**++The ticket's step 2 said not to skip the live tests for a green tick, and to+consider whether the runner can be made to host WebKit before excluding+anything. It can: WebKit works on GitHub's macOS runners, and the evidence says+this was never a capability problem but a scheduling one. So **no suite is+excluded**, under any locale — the sweep still runs the live WebKit tests, and+the per-configuration zero-test guard still reads a full-sized count.++The perf budgets are a genuinely different measurement and are scaled, not+skipped. Scaling uses a **named constant with a comment**, following the+convention already established in three other suites, so the requirement figure+stays legible in the source (`budget(ms: 300)` for Req 9.2) and the allowance is+visible next to it rather than baked into a nudged number. The trade-off is+stated in the code: at 20x these tests catch a catastrophic regression and not a+modest one.++T-1986's expectation was made non-strict because it failed on CI with "Expected+failure ... but none recorded" — on the runner recolor *was* faster, so XCTest+failed the test for the defect not reproducing. A strict expectation asserts the+bug must be present on every machine, which reports the problem being fixed as a+failure. Non-strict keeps the finding recorded without that.++**Alternatives considered:**++- **Skip the live WebKit suites on CI** (a named `-skip-testing:` list) — this is+  what the ticket explicitly warned against, and the evidence did not justify it:+  the tests are not incapable of running on the runner, they were starved.+- **Widen the perf budgets in place** (60 -> 70ms) — rejected: it erases the+  requirement figure and would need redoing on the next runner generation.+- **Change entitlements** — nothing was missing; `com.apple.security.network.client`+  is already present in `prism-ci.entitlements` and the built app carries it.+- **Reduce the sweep to fewer locales** to buy back the serialisation cost —+  rejected as T-1983's design decision, not this ticket's to reverse.++## Regression Test++**Test file:** `Tools/Tests/test-make-guards.sh`+**Test name:** check `[8] every test run is serialised to one worker (T-2146)`++**What it verifies:** for every test-running target, it expands the recipe with+`make -n`, reassembles backslash-continued logical lines (exactly as make hands+them to the shell), splits each at command separators (`;`, `|`, `&`), and+requires `-parallel-testing-worker-count 1` **inside every fragment** that+invokes `xcodebuild test` / `test-without-building`. Pairing the pin with each+invocation, rather than comparing aggregate counts, is deliberate: the first+version of the check compared totals ("N test runs, N pins anywhere in the+recipe"), which fails open — a pin moved off a real test run and onto the+`build-for-testing` step keeps the totals equal while that test run goes back to+unbounded workers. The check carries that reviewer-found fail-open as a+permanent in-script negative case: a mutated copy of the Makefile with exactly+that moved pin must be reported unpinned.++**Red/green:** against the unfixed Makefile it reports+`test-locales: 2 of 2 test run(s) lack their own -parallel-testing-worker-count 1+— they run unbounded parallel test hosts (T-2146)` and exits non-zero; after the+fix all four targets pass, and the negative case demonstrates red permanently.++**Run command:** `make verify-make-guards`++## Validation++Verified locally:++- `make verify-make-guards` — passes, including new check [8]; confirmed to fail+  before the Makefile change.+- `make lint` — 0 violations in 528 files.+- `xcodebuild build-for-testing` (ad-hoc signed, macOS) — **TEST BUILD+  SUCCEEDED**; the edited test file compiles with no new warnings.++**Not verified, and this should be read as the main open risk:**++- The suite itself could not be executed on the developer machine, for the+  `_libsecinit_appsandbox` reason above.+- GitHub Actions billing for this account was blocked on 2026-08-10 and is still+  blocked: recent runs fail in ~4 seconds with "The job was not started because+  recent account payments have failed". So this branch's own sweep has not run,+  and **the fix has not been observed working on CI**.++The causal argument is strong — every non-perf failure in the bundle is+starvation-shaped, and the fix removes the oversubscription that produced it —+but it remains an inference until a sweep runs. The next sweep after billing is+restored is the real test. If timeouts persist at worker count 1, the runner's+capability to host WebKit becomes the live hypothesis again, and *that* is the+point at which an explicit, named exclusion should be considered.++A secondary risk is duration: four serial runs of 4,240 tests plus the UI tests+may approach the raised 150-minute cap. The measured numbers bound the risk+(step timing from the GitHub API, phase split from the run log, both for+31366718394): the whole sweep step took 8m25s — `build-for-testing` 5m22s+(07:39:13 → 07:44:35), then the entire "en (base)" test phase ~3 minutes+(07:44:35 → 07:47:37) *including* all 143 thirty-second timeouts, which must+therefore have overlapped rather than summed (143 × 30s alone is ~71 minutes).+The run bailed after that first configuration, so a full sweep multiplies only+the test phase by four plus the UI tests; the pin removes host-level+parallelism of at most ~3 hosts on a 3-core runner. A realistic serial sweep+is tens of minutes, not 150. If the sweep times out rather than failing, that+is still the thing to look at first.
specs/bugfixes/ci-live-webkit-test-failures/implementation.md Added (this review) +185 / -0
diff --git a/specs/bugfixes/ci-live-webkit-test-failures/implementation.md b/specs/bugfixes/ci-live-webkit-test-failures/implementation.mdnew file mode 100644index 0000000..c3b0156--- /dev/null+++ b/specs/bugfixes/ci-live-webkit-test-failures/implementation.md@@ -0,0 +1,185 @@+# Implementation Explanation: T-2146 — Serialise the CI Sweep's Test Workers++Branch `T-2146/bugfix-ci-live-webkit-test-failures` (PR #361) vs `origin/main`.+Two commits: the fix (`101e7a5`) and one review round (`436313b`).++## Beginner Level++### What Changed++The project's automated test job on GitHub kept failing: 151 tests out of+4,240 went red the first time the job ever really ran them. Almost all of+those failures (143) were the same message — a web page inside a test waited+30 seconds to load and gave up.++The cause was not the tests or the web pages. The test tool (`xcodebuild`)+was allowed to decide for itself how many copies of the app to run at once.+On the small cloud machine GitHub provides (3 CPU cores, 7 GB memory), it+started several full copies of the app simultaneously, and each copy also+spawns its own browser helper processes. The machine was so overloaded that+page loads never got a turn. The fix is one flag —+`-parallel-testing-worker-count 1` — telling the tool to run one copy at a+time.++### Why It Matters++This job is the only automated test signal the project has. While it was+red, every change landed without a working safety net. The fix also+deliberately keeps *all* tests running — the tempting shortcut of switching+the flaky ones off would have made the job look healthy while quietly+testing less.++### Key Concepts++- **CI (Continuous Integration)**: a cloud machine that runs your tests on+  every change. Think of it as a robot proofreader.+- **Test worker / test host**: a copy of the app launched just to run tests+  inside it. Here each copy is heavyweight — a whole app that opens real+  web views.+- **Resource starvation**: too many processes competing for too few CPU+  cores, so some never get scheduled. Like nine people sharing three chairs:+  nobody sits long enough to finish anything.+- **Regression guard**: a small script check that fails the build if someone+  later removes the fix by accident.++## Intermediate Level++### Changes Overview++- `Makefile` — `test-locales` (the only target CI runs) gains+  `-parallel-testing-worker-count 1` on both of its test invocations: the+  per-locale `test-without-building` loop and the `prismUITests` run. The+  other three test targets already had the pin; this one had been forgotten+  because it executed nothing at all until T-1983 repaired it.+- `Tools/Tests/test-make-guards.sh` — new check [8]. For every test target it+  expands the recipe with `make -n`, reassembles backslash-continued logical+  lines, splits them at command separators, and requires the pin *inside+  every fragment* that invokes `xcodebuild test`/`test-without-building`. A+  permanent negative case mutates a copy of the Makefile (pin moved off the+  UI-test run onto `build-for-testing`) and asserts the check still reports+  an unpinned run.+- `.github/workflows/localisation-tests.yml` — `timeout-minutes` 90 → 150,+  because serialising raises wall-clock; plus a comment recording that+  nothing is excluded from the sweep.+- `prismTests/RawSourceHighlightingPerformanceTests.swift` — the five+  absolute wall-clock budgets adopt the project's existing 20x+  `ciPerformanceMultiplier` convention via a `budget(ms:)` helper, and the+  T-1986 `XCTExpectFailure` becomes non-strict with an `issueMatcher`+  scoped to its one assertion.+- `docs/agent-notes/development-tooling.md` — documents the machine-wide+  `_libsecinit_appsandbox` hang that currently prevents executing the suite+  locally, and what remains verifiable despite it.+- `specs/bugfixes/ci-live-webkit-test-failures/report.md` — the bugfix+  report, including the honest caveat that the fix is unproven on a real+  runner (GitHub Actions is billing-blocked).++### Implementation Approach++The diagnosis came from the actual result bundle of run 31366718394 (still+within retention), not the ticket summary. All 151 failures classified into+starvation-shaped buckets (143 load timeouts, 3 SIGSEGVs, 2 async ordering /+race flakes) plus 3 that are genuinely different: two perf budgets missed by+3–7% and one inverted `XCTExpectFailure`. The fix therefore has two prongs:+serialise the workers (starvation), and scale the wall-clock budgets+(shared-hardware variance) — never skip a suite.++Check [8]'s design is the notable part: it pairs the pin with each+invocation rather than comparing counts, because the aggregate version+fails open (a pin moved onto `build-for-testing` keeps the totals equal+while a real test run goes unbounded). The reviewer's reproduction of that+fail-open is kept as a permanent negative case.++### Trade-offs++- **Serial vs parallel**: four serial full-suite runs plus UI tests cost+  wall-clock (hence 90 → 150 min). Accepted because the alternative was a+  red-or-dishonest job. Pinning only the WebKit-heavy suites was not+  attempted; the project's other targets all pin to 1 for shared-global-state+  reasons, so this follows the established conclusion.+- **20x budget scaling**: keeps the requirement figure legible+  (`budget(ms: 300)` for Req 9.2) at the cost of only catching catastrophic+  regressions; the relative-comparison tests still catch gradual drift.+- **Non-strict expectation**: stops CI failing when T-1986's bug does *not*+  reproduce, at the stated cost of swallowing any same-assertion failure+  regardless of magnitude and losing the "looks fixed" signal.++## Expert Level++### Technical Deep Dive++The `prism.xctestplan` marks `prismTests` `"parallelizable": true`, so+absent an explicit worker count xcodebuild sizes the pool from the machine+and runs multiple test *hosts* — each a full `WindowGroup` app whose live+WebKit suites (`SpikeWebPageHarness`) open real `WebPage`s, each with its+own WebContent and GPU XPC helpers. On a 3-core/7 GB runner the process+population exceeds schedulable capacity and page loads (30 s allowance)+never run: 143 × `.loadTimedOut`. The 2-of-3 WebKit SIGSEGVs and the two+ordering/race failures are the same starvation wearing different clothes.++`pin_audit` in check [8] handles the two lexical hazards of auditing a+`make -n` expansion: backslash continuations (awk buffers until a line+without a trailing `\`, exactly mirroring how make hands the logical line to+the shell) and compound commands (splitting on `[;|&]` so a pin in one+fragment cannot vouch for a sibling — note `$(PIPE_PRETTY)`'s `|` splits the+xcodebuild fragment *before* the pipe, which is why the pin must precede it,+and it does). The xcodebuild-detection regex anchors `test`/+`test-without-building` as whole words, so `build-for-testing` is rightly+outside the audit — which is precisely the hole the negative case pins.++The T-1986 expectation inversion is worth understanding: a strict+`XCTExpectFailure` asserts the defect reproduces on *every* machine. On the+runner, recolor genuinely was faster than parse, so XCTest failed the test+with "Expected failure … but none recorded" — reporting the bug's absence+as a failure. `nonStrict()` removes that direction; the `issueMatcher`+(`.assertionFailure` + message match on "Recolor should be faster than+initial parse") keeps the expectation from swallowing crashes or unrelated+assertions added later.++### Architecture Impact++Minimal and contained: no production code changes. The one structural+commitment is that the CI sweep is now defined to be serial — any future+attempt to re-parallelise must get past check [8], which is intentional+friction. The agent-notes addition changes what local validation *means* on+this machine: build-for-testing, lint, and verify-make-guards are the+verifiable surface; suite execution is not.++### Potential Issues++- **The fix is unproven on a real runner.** GitHub Actions is+  billing-blocked, so the causal argument (strong: every non-perf failure is+  starvation-shaped) remains an inference until a sweep runs. If timeouts+  persist at worker count 1, runner WebKit capability becomes the live+  hypothesis and a named exclusion becomes defensible.+- **Duration risk**: four serial runs of ~4,240 tests plus UI tests may+  approach the 150-minute cap. A timeout on the next sweep should be read as+  a duration problem before a hang.+- **Sensitivity swallowed**: at 20x, absolute perf budgets only catch+  order-of-magnitude regressions; the non-strict expectation can no longer+  distinguish "5% slower" from "5x slower" on that one assertion.+- **`pin_audit` is lexical**: a future recipe that hides the xcodebuild+  invocation behind a variable or a helper script would evade the audit+  (it would then fail the "expands to no test-running invocation" arm,+  which fails closed — the acceptable direction).++## Completeness Assessment++- **Fully implemented**: worker pinning on both `test-locales` invocations;+  check [8] with per-invocation pairing and the permanent negative case;+  perf-budget scaling following the existing convention; scoped non-strict+  T-1986 expectation; timeout bump; agent-notes documentation; bugfix+  report with the unproven-on-CI caveat stated prominently.+- **Partially implemented (by circumstance, not omission)**: end-to-end+  validation — the suite cannot execute on the developer machine+  (machine-wide `_libsecinit_appsandbox` hang) and CI is billing-blocked,+  so the fix is verified at the guard/lint/compile level only.+- **Missing**: nothing identified against the ticket's scope. A pre-push+  review round found the report's Regression Test section still describing+  the superseded counting version of check [8]; that and several hardening+  items (scoping the negative-case mutation to the `test-locales` recipe, a+  mutation-applied pre-check, a shared constant tying the T-1986+  `issueMatcher` to its assertion message) were fixed before push. The+  pre-check surfaced a real trap worth remembering: under `set -o pipefail`,+  `diff | grep -q` fails on success — `-q` exits at the first match, which+  kills `diff` with SIGPIPE and fails the pipeline — so the check uses+  `grep -c` instead.

Things to double-check

The fix is unproven on a real runner.

GitHub Actions is billing-blocked (runs die in ~4s with the account-payment annotation). The first sweep after billing is restored is the real test: if .loadTimedOut persists at worker count 1, the runner's capability to host WebKit becomes the live hypothesis, and that is the recorded point at which a named exclusion becomes defensible.

The suite was not executed locally.

Every sandboxed launch of prism.app on this machine hangs pre-main in _libsecinit_appsandbox (documented in the diff's agent-note). Verification here is guards + shellcheck + lint + build-for-testing — the edited test file compiles, but no test in it ran.

Duration headroom is measured on a red run.

The measured figures (verified against the GitHub API step timing and the run log for 31366718394: sweep step 8m25s — build 5m22s, en (base) test phase ~3 minutes including all 143 overlapping timeouts) cover build plus one configuration that bailed early. A green serial sweep does strictly more work; the ~40–80 minute projection for a full serialised sweep is an inference. If the next sweep hits 150 minutes, read it as duration first, hang second.

Pre-existing test-target warnings.

build-for-testing surfaces warnings not introduced by this diff (e.g. an unused token loop variable in this same test file, and a SpikeWebPageHarness shadowing note). They predate the branch and are out of scope, but a zero-warnings pre-push policy will meet them eventually.