prism branch T-1951/bugfix-… commits 3 + review fixes files 7 touched lines +779 / -45 findings 8 raised / 2 fixed

Pre-push review: T-1951 quadratic scans in the raw-HTML image path

PR #358 — three quadratic scans replaced with linear ones: HTMLImageParser's void-element normalisation and comment stripping, and HTMLImageSourceRewriter.splitSrcsetCandidates. Third review round; two prior rounds passed. CI is billing-blocked, so validation is local.

At a glance

  • The serious defect: void-element normalisation ran unconditionally at parse time on every raw-HTML block — 35 s for 70 KB of half-written <img  tags, denial-of-service shaped because documents open from URLs. Now 0.45 ms.
  • No regex spelling is linear here: the cost is a failing attempt scanning to EOF and being retried from the next start, not intra-attempt backtracking — so the fix is a forward-only cursor scan, not a better regex.
  • Equivalence rests on differential fuzzing (1.2 M + 600 k fragments, zero mismatches) against the retired expressions kept verbatim as oracles — it caught a real bug in the first draft that code reading missed.
  • One deliberate, pinned divergence: ASCII-only tag-name folding drops ICU's U+017F ſ→s fold (<ſource> no longer normalised) — HTML5-correct, golden asserts the oracle disagrees.
  • Scope discipline verified: quote-awareness (T-1976) and the sibling HTMLCommentStripping regex (T-2147) deliberately left standing, both tickets confirmed to exist in Transit with accurate descriptions, both pinned by goldens or KNOWN QUADRATIC comments.
  • Reviewer verdicts: reuse — ICUWhitespace is a deduplication, both existing test helpers reused not redefined; quality — every stated cache invariant holds; efficiency — genuinely linear, tests add ~4–6 s wall time.

Verdict

Ready to push

Three independent review agents (reuse, quality, efficiency) found nothing above nit level. The quality reviewer hand-traced all three scans against the retired regexes — template equivalence, cursor monotonicity, cache invariants, lazy-group semantics — and every edge agreed with the oracle. The efficiency reviewer verified linearity on every constructible input shape, including the cache-thrash scenarios. Two comment-only nits were fixed in the working tree; the rest are deliberate trade-offs or test-file suggestions not worth a round-trip. Lint and the four targeted suites pass locally after the fixes.

Review findings

8 raised · 2 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism displays markdown files that can contain raw HTML, including images. Three of the steps that prepare that HTML for display did their work in a way that got dramatically slower as the input grew: doubling the input quadrupled the time. That growth pattern is called quadratic, and it turns harmless-looking documents into freezes — 70 KB of half-written <img  tags (what a document looks like mid-write or truncated) took 35 seconds.

Why It Matters

Prism can open documents from a URL, so the slow input can be someone else's file: a crafted (or merely truncated) document could keep the app busy for a long time on a stranger's behalf. That makes this a denial-of-service fix, not just a speed-up. All three steps now read the text once, front to back, and take a few milliseconds at most.

Key Concepts

  • Regex (regular expression) — a pattern language for finding text. The old code used lazy wildcard patterns that, on inputs with many starts and no end, re-scanned to the end of the file from every start.
  • Linear scan — reading each character once, like a bookmark moving only forward. The replacement code does this.
  • Differential fuzzing — generating over a million random inputs and checking the old and new code give identical answers, character for character. This is how the change proves it only changes speed, not behaviour.

Changes Overview

  • prism/Services/HTMLImageParser.swift — two NSRegularExpressions retired: (?i)<(img|source|br)(\s[^>]*?|)(?<!/)\s*> (void-element normalisation) and <!--[\s\S]*?--> (comment stripping), each replaced by a hand-written forward-only UTF-16 scan.
  • prism/Services/WebRendering/HTMLImageSourceRewriter.swiftsplitSrcsetCandidates's per-character trimmingCharacters().lowercased().hasPrefix("data:") on the growing accumulator replaced by a bounded 6-character probe.
  • prism/Services/ICUWhitespace.swift (new) — the ICU \s set (whitespacesAndNewlines minus U+200B), hoisted from the rewriter and shared.
  • prismTests/RawHTMLImageScanGrowthTests.swift (new) — 9 growth-ratio guards + goldens + 4 seeded differential fuzz tests, with the retired implementations kept verbatim as oracles.

Implementation Approach

Both scans exploit the same two properties of the retired patterns: (1) a match starting at an opener must end at the first closer (> / -->) at or after it, because neither [^>] nor the lazy wildcard can cross one; and (2) those closer lookups arrive at non-decreasing positions, so a forward-only cursor answers all of them in one pass — once no closer remains, no later opener can match. The void-element scan adds a one-slot whitespace-run cache so many tag starts sharing one distant > don't re-walk the same run (guarded by test G3). The srcset probe observes that the data: verdict turns on the first five post-whitespace characters and is settled after six.

Trade-offs

  • Hand-written scan over regex: no regex spelling is linear here (atomic groups don't help — the cost is failed-attempt retries, not backtracking), so the readability cost of ~90 lines of scan is the price of the complexity class.
  • Bug-compatible by design: the scan reproduces the regex's quote-blindness (<img src="a>b"> still mangled, T-1976) because the change's whole warrant is that nothing observable moves.
  • Growth ratios over absolute budgets: tests measure at N and 4N with a ratio ceiling, because absolute timing budgets are documented as flaky under concurrent xcodebuild load (T-1541).

Technical Deep Dive

The monotonicity argument for the shared > cursor has one subtle leg: firstClose is called with index + 1 + nameLength, and name lengths vary (2–6), so a later call could in principle carry a smaller position. It cannot: for that, a < would need to sit within 3 units after a just-matched name's <, but those units hold the name's own letters. The one-slot whitespace-run cache is sound because end is always the current (non-decreasing) close and runs for distinct > are disjoint — the earlier > truncates the later run's back-walk. Replacement emission is byte-identical to the template <$1$2 /> because copying [copied, groupEnd) verbatim preserves the original case that $1$2 re-emits. The lookbehind (?<!/) maps to: step the group end past a trailing / by one character (putting whitespace under the lookbehind), which is exactly how <img / > becomes <img / /> — pinned as a golden lest it read as a bug.

The srcset probe's grapheme subtlety: probe.count > 5 settlement is safe because an appended Character can only merge into the last grapheme, freezing the first five; and the Character-vs-scalar difference from the retired trim can only diverge on clusters mixing whitespace and non-whitespace scalars, where both sides fail hasPrefix("data:") anyway. Context-sensitive lowercasing (İ, Σ/ς — in the fuzz alphabet) cannot manufacture d-a-t-a-:.

Architecture Impact

ICUWhitespace becomes the single source of truth for a genuinely surprising set (ICU's \s ≠ any one Foundation CharacterSet; U+200B is the lone disagreement) that every regex-to-scan replacement in this area depends on. The KNOWN QUADRATIC comment on HTMLCommentStripping.inlineCommentRegex plus T-2147 turns the remaining sibling into tracked debt with the fix pattern named. Three functions widened private → internal solely so fuzzing can compare them against their oracles directly — parse's joined-up output cannot distinguish every normalisation.

Potential Issues

  • Fuzz alphabets cannot generate \r/CRLF or an all-uppercase SOURCE — near-zero risk (both sides treat \r identically; case folding is per-unit-uniform) but cheap to close on a future touch.
  • The U+017F divergence means a document spelling <ſource> renders differently than before — deliberately, per HTML5 tag-name matching, pinned by a golden that asserts the oracle disagrees.
  • The new .serialized suite adds ~4–6 s to make test-quick; the quadratic oracles only ever see ≤ ~1 KB fuzz inputs so they cannot themselves blow up the suite.

Completeness Assessment

Fully implemented: all three scans linearised with growth guards at both the function and the public parse/rewrite entry points; equivalence established by goldens + seeded fuzz; scoped-out siblings ticketed (T-1976, T-2147 — both verified to exist in Transit with accurate descriptions). Partial/missing: nothing within this PR's stated scope.

Important changes — detailed

HTMLImageParser: void-element normalisation regex → linear scan

prism/Services/HTMLImageParser.swift

Why it matters. The serious one: runs unconditionally at parse time on every raw-HTML block up to 1 MB, before the rewriter is ever reached. 0.64 s at 8 KB rising to 35 s at 70 KB of half-written <img tags — reachable from a URL-opened document, so denial-of-service shaped. Now 0.45 ms.

What to look at. HTMLImageParser.swift — normaliseSelfClosingTags, firstClose, whitespaceRunStart, matchedVoidElementLength, asciiLowercased

Takeaway. When a lazy-wildcard regex is quadratic on adversarial input, no regex respelling fixes it — the cost is a failing attempt scanning to EOF and being retried per start. The linear replacement rests on two derivable pattern properties: the match must end at the first closer, and closer lookups arrive in increasing order, so one forward-only cursor answers them all.
Rationale. Stated in the doc comment and PR: greedy/atomic/possessive variants don't help, and the 1 MB input cap is four orders of magnitude too generous to serve as mitigation. The one-slot whitespace-run cache exists because many starts can share one distant `>` — re-walking that run per start would reintroduce the quadratic (guarded by test G3, a shape that was never slow before the fix).

HTMLImageParser: comment-strip regex → linear scan (found in review)

prism/Services/HTMLImageParser.swift

Why it matters. Identical lazy-wildcard shape, sitting one line earlier in the same unconditional parse path — 2.3 s at 32 KB of unclosed <!-- openers. Found during review of this very fix, not by the original ticket.

What to look at. HTMLImageParser.swift — stripHTMLComments

Takeaway. A fix's review is the best time to sweep for the same defect shape in the surrounding path: this scan sat one line above the reported one and had identical growth. Simpler than the void-element scan — no shared-closer cursor needed, because a successful match jumps past its close (disjoint scan regions) and a failed close-search terminates the whole loop.
Rationale. Widened into this PR because it is the same shape in the same call path; the separate HTMLCommentStripping helper carrying the same regex on other call paths was deliberately NOT widened in — its extractor consumers carry capture-group semantics deserving their own equivalence work (T-2147, filed with the linear fix pattern named).

HTMLImageSourceRewriter: splitSrcsetCandidates data: test on a bounded probe

prism/Services/WebRendering/HTMLImageSourceRewriter.swift

Why it matters. Two full copies of the accumulated candidate per character to inspect five characters of it — 2.8 s for a 62 KB srcset value, an ordinary thing for a document to carry. Now 1.4 ms.

What to look at. HTMLImageSourceRewriter.swift — splitSrcsetCandidates (probe / probeSettled)

Takeaway. An accumulate-and-rescan loop is quadratic even without a regex: current.trimmingCharacters().lowercased().hasPrefix() re-derived a five-character verdict from the whole accumulator on every character. The fix bounds the question, not the input: the verdict turns on the first five post-whitespace characters and is settled after six.
Rationale. The probe deliberately keeps the retired expression's exact spelling (Character-level trim, `.whitespaces` not ICUWhitespace) applied to bounded input, so the 600 k-round fuzz certifies equivalence rather than an argument about CharacterSet semantics. The dropped per-space emptiness re-check is justified inline: a candidate holding `data:` is never whitespace-only.

ICUWhitespace: the ICU \s set, defined once with evidence attached

prism/Services/ICUWhitespace.swift

Why it matters. Every regex-to-scan replacement in this area is only byte-identical to its retired pattern if it agrees with ICU on exactly which code points \s matches — a surprising set no single Foundation CharacterSet spells. Getting it wrong shifts a tag boundary silently.

What to look at. ICUWhitespace.swift (new, 38 lines); HTMLImageSourceRewriter.swift — isWhitespaceUnit now delegates

Takeaway. ICU's \s equals CharacterSet.whitespacesAndNewlines minus U+200B (ZERO WIDTH SPACE) — Foundation counts U+200B as whitespace, ICU does not — probed empirically against NSRegularExpression rather than trusted from documentation. Members are all BMP, so lone surrogates can be tested per-UTF-16-unit without pairing.
Rationale. Hoisted from HTMLImageSourceRewriter's private attributeWhitespace (T-1655) the moment a second consumer appeared, because the definition is re-derivable only by re-running the probe — exactly the kind of knowledge that should live in one documented place.

RawHTMLImageScanGrowthTests: growth ratios + differential oracles

prismTests/RawHTMLImageScanGrowthTests.swift

Why it matters. The equivalence mechanism is the load-bearing part of the whole PR: pure performance changes are only safe if nothing else moves, and 465 lines of tests are what establish that.

What to look at. RawHTMLImageScanGrowthTests.swift — 3 suites, 9 growth guards (G1–G9), goldens, 4 seeded fuzz tests with retired implementations as oracles

Takeaway. Differential fuzzing against the retired implementation kept verbatim beats reasoning about equivalence: it caught the first draft's NSString.range(of:) bug (a `>` carrying a combining mark silently skipped) that code reading missed. Goldens assert BOTH implementations, so a wrong golden is caught by the oracle side; the one deliberate divergence golden asserts the oracle DISAGREES, so a stale divergence note fails loudly too.
Rationale. Growth measured at N and 4N with a ratio ceiling, never an absolute budget — absolute budgets are documented as flaky under concurrent xcodebuild load (T-1541). Suites are .serialized so base and 4x measurements share contention. Guards pin both the scan directly and the public parse/rewrite entry points, so the fix is pinned where the denial of service lived.

Scope pins: T-1976 preserved by golden, T-2147 documented in place

prism/Services/HTMLCommentStripping.swift

Why it matters. The PR's warrant is that nothing observable moves, so its two deliberate non-fixes are made un-silent: a golden fails if quote-awareness is ever added casually, and a KNOWN QUADRATIC comment marks the sibling regex that still carries the shape.

What to look at. HTMLCommentStripping.swift:22-28 (KNOWN QUADRATIC comment); RawHTMLImageScanGrowthTests.swift — quotedCloseBracketStillMangles

Takeaway. A deliberately-preserved defect deserves a test that fails when someone fixes it silently: the T-1976 golden asserts the mangled output AND oracle agreement, so closing the defect requires touching a test that names the ticket. Both tickets verified to exist in Transit with descriptions matching the code's claims.
Rationale. Quote-awareness is orthogonal to linearity (it changes where a tag ends, not how the end is found) and by construction disagrees with the retired regex, so it needs its own differential corpus — it cannot ride a change whose evidence is agreement with that regex.

Key decisions

Hand-written scan over any regex respelling.

No regex spelling of the void-element pattern is linear: greedy [^>]* cannot cross a > either, and atomic groups/possessive quantifiers address intra-attempt backtracking, while the cost here is a failing attempt that scans to EOF and is retried from the next start. Capping input doesn't help — the existing 1 MB cap is ~4 orders of magnitude too generous.

Equivalence by differential fuzz, not by reasoning.

1.2 M generated fragments for the void-element scan, 600 k for the split, against the final production code; 50 k of each pinned with a seeded generator. The doc comments explicitly demote their own pattern-reading to explanation, not evidence. The mechanism earned its keep: the first draft's NSString.range(of:) lookup missed > under a combining mark — unfindable by review.

U+017F (long s) divergence kept and pinned.

ICU's (?i) folds ſ→s, so the retired regex normalised <ſource>; the scan folds ASCII-only, which is what HTML5 itself specifies for tag names. Pinned by a golden that asserts the oracle disagrees — and ſ is excluded from fuzz alphabets so the genuine divergence can't read as a fuzz mismatch. U+017F is the only such code point for these names (the only other ASCII-targeted simple fold is Kelvin→k; no name has a k).

Quote-awareness (T-1976) deliberately not fixed.

<img src="a>b"> is still mangled, exactly as [^>] mangled it. Fixing it here would smuggle an observable behaviour change into a change whose whole warrant is byte-identity with the retired regex — and would need its own differential corpus since it disagrees with that regex by construction. A golden fails loudly if a later change closes it silently.

HTMLCommentStripping's identical regex left standing (T-2147).

The separate helper carries the same <!--([\s\S]*?)--> shape on other call paths, including extractor consumers with capture-group semantics that deserve their own equivalence work. Marked KNOWN QUADRATIC in place with the linear replacement pattern named; T-2147 filed with all four reachable surfaces enumerated.

Growth ratios at N vs 4N, never absolute budgets.

An absolute budget records what one machine did on one day and is documented flaky under concurrent xcodebuild load (T-1541). The two absolute guards T-1655 left in BlockHTMLEmitterMediaTests are kept as-is — their comments now point at the shared GrowthRatioGuard, and pinning the exact reported input at its reported size is worth more than restating a complexity class guarded elsewhere.

Three functions widened private → internal for the fuzz.

splitSrcsetCandidates, stripHTMLComments, and normaliseSelfClosingTags are internal so the differential fuzz can compare them against their oracles directly — the joined-up parse/rewrite output cannot distinguish every intermediate result. Same-module visibility only; the rationale is now documented on all three (the two parser functions gained theirs in this review).

The srcset probe keeps the oracle's exact spelling on bounded input.

The per-character String(character).trimmingCharacters(in: .whitespaces) allocates, and .whitespaces is deliberately not ICUWhitespace — it is the retired expression's own set. A cheaper scalar predicate would trade fuzz-certified equivalence for an unmeasurable constant.

Review findings

SeverityAreaFindingResolution
nitHTMLImageParser.swift — visibility rationalestripHTMLComments and normaliseSelfClosingTags were widened private → internal for the differential fuzz, but only splitSrcsetCandidates documented why.Added the one-sentence rationale to both doc comments, matching the rewriter's precedent.
nitHTMLImageParser.swift — firstClose`max(position, closeCursor + 1)` is provably just `position` (the line is only reached when closeCursor < position), which slightly undercuts the non-decreasing-calls comment above it.Extended the comment to state the max is defensive against a monotonicity break, not a live case.
nitRawHTMLImageScanGrowthTests.swift — fuzz alphabetsNo alphabet can generate \r/CRLF, and the void-element random alphabet cannot assemble an all-uppercase SOURCE (missing O,U,C,E uppercase). Near-zero risk: \r sits in the same whitespace class as the covered \n on both sides, and case folding is per-unit-uniform.Skipped — test-file change, both reviewers rated it not worth a round-trip; noted for a future touch.
nitRawHTMLImageScanGrowthTests.swift — fuzz loop duplicationImageParserCommentStrippingTests re-inlines the generate-and-compare loop that VoidElementNormalisationTests.expectFuzzAgreement already wraps (identical String→String shape).Skipped — test-file refactor; the srcset suite legitimately differs ([String] result), so the shared helper would cover only two of three suites.
nitHTMLImageParser.swift — literal-match loopsThe local matches(_:at:) in stripHTMLComments and matchedVoidElementLength are same-shaped ~8-line loops (exact vs case-folded match).Skipped — both reviewers judged hoisting adds indirection for nothing; a codebase-wide helper would need an abstraction heavier than the loops it replaces.
nitHTMLImageSourceRewriter.swift:329 — per-character allocationString(character).trimmingCharacters(in: .whitespaces) allocates a String per leading-whitespace character. Linear and bounded, but a scalar predicate would be cheaper.Skipped deliberately — the whole-Character formulation is the retired expression's own spelling, which is what makes the fuzz-certified equivalence argument go through; both reviewers flagged it only so a future optimisation doesn't swap it without re-fuzzing.
nitHTMLImageParser.swift — output capacityoutput never calls reserveCapacity; geometric growth makes this amortized-irrelevant.Skipped — no measurable effect.
nitprismTests — suite wall timeThe new .serialized file lands around 4–6 s in make test-quick (fuzz oracles dominate; benchmarked empirically). The quadratic oracles only ever see ≤ ~1 KB fuzz inputs, so the cost is capped by design.Skipped — noticeable but not egregious; the ratio-not-budget design is the right call for this repo's documented timing-flakiness history.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5224d5a..4e922fb 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Documents containing images written in raw HTML no longer stall while opening (T-1951). Three steps of that work cost time in proportion to the *square* of what they were given, so a document that would otherwise open instantly could hold the app for tens of seconds. Half-written image tags — a run of `<img ` with no closing bracket, which is what a document being written, generated, or truncated mid-tag looks like — were the worst of it: 0.6 seconds for 8 KB of them, 35 seconds for 70 KB, and that step runs on every piece of raw HTML in a document before anything is drawn. A single image carrying a long list of alternative sizes cost 2.8 seconds for a 62 KB list. And the removal of HTML comments, which runs even earlier on the same raw HTML, slowed the same way on a run of comment openers with no close — 2.3 seconds for 32 KB of them — a case found while this fix was being reviewed rather than by the original report. All three now take a few milliseconds at most, and grow in step with the length of the document rather than with its square. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. Nothing about how such documents are displayed changes: each replacement was checked against the exact step it replaced over more than a million generated fragments, character for character — with one deliberate exception too archaic to meet in practice, an image tag spelled with the mediaeval long-s (`<ſource>`), which the old matching treated as `<source>` and the new, standards-following matching does not. One thing deliberately left standing is a `>` written inside a quoted attribute value, as in `<img src="a>b">`, which still ends the tag early and is tracked separately (T-1976). With these three, every step of the raw-HTML image path that was known to slow down this way has now been fixed; one same-shaped scan on the neighbouring path — the comment removal applied when raw HTML is displayed as-is rather than as an image — was found while fixing these and is tracked separately (T-2147). - Choosing where to go while a document reloads now takes you there (T-1975). If a file changed on disk — or a URL document was refreshed — while you had it open, and during the moment the app spends preparing the new version you picked a table-of-contents entry, tapped a note, followed a link to a heading, or stepped to a search match, the reloaded document appeared at your saved reading position instead. What you chose was handed to the copy still on screen, which was about to be replaced, so nothing was left to say where you had asked to go and restoring your place won — and on a large document, where preparing the new version takes longest, that window is at its widest. The document on screen is now treated as superseded from the moment a reload starts rather than from the moment the new version is ready, so anything you choose in between is held for the version that is coming and takes precedence over your saved place, exactly as it already did when you chose a moment later. This holds when a file changes twice in quick succession, so a second reload beginning before the first has finished preparing still takes you where you asked rather than back to your saved place. Reloads you did not navigate during still return you to where you were reading, and once the reloaded document has taken you where you asked, the next reload restores your place normally. Scrolling while a reload prepares still counts too, however you do it — dragging, a trackpad or wheel, **Page Up** and **Page Down**, or **Scroll to Top** and **Scroll to Bottom** — because the document stays in front of you and stays scrollable the whole time: the place you scroll to is the place you are returned to. - Changing the reading font or text size no longer moves you somewhere else in the document (T-1965). Both settings already applied without reloading, but they reflow the whole document and nothing put you back afterwards: raising **Larger Text** to an accessibility size makes every block roughly three times as tall, so the text you were reading slid off the bottom of the screen and left you looking at something you had already been through. The app then recorded that new spot as where you were reading, so closing and reopening the document returned you to it as well. Your place is now kept across the change — including how far into a paragraph you were, so the same words stay in front of you rather than merely the same paragraph starting at the top — and a place you were never reading can no longer be saved while the document settles. Jumping somewhere while the change is settling wins: a table-of-contents entry, a link, a note, or a search match all take you where you asked, and the re-anchoring steps aside. Collapsing the section you were reading during the change leaves you at its heading rather than at content that is no longer shown. - A document that goes blank because its rendering process stopped now restores itself (T-1943). The app has always been able to recover from this — it reloads the document and puts back your theme, your reading position, your note markers, and any active search highlights — but nothing was ever watching for the rendering process to stop, so the recovery never actually ran. A large or image-heavy document whose renderer was shut down under memory pressure therefore showed an empty page, with no error and no way back except closing the file and opening it again. The app now watches for it and recovers on the spot. An ordinary failure to load — a link that goes nowhere, an image that cannot be fetched — is told apart from a stopped renderer, so it neither causes a needless reload nor stops the app watching for a real one afterwards. The recovery also covers its own failure: if the reload it starts cannot itself load the document, that counts as the recovery failing and is tried again, instead of leaving the page blank with nothing running. A reload that neither succeeds nor fails — one that simply never finishes — is covered too: it is given a generous time limit, well beyond what even a large document takes to appear, and is then treated as a failed recovery and tried again rather than leaving the page blank indefinitely. If reloading repeatedly fails to bring the document back, the app stops retrying rather than reloading over and over — and says so, with a banner offering to reload. Taking that reload also restores the document's ability to recover on its own again, so giving up is never permanent while the file stays open.
prism/Services/HTMLCommentStripping.swift Modified +8 / -0
diff --git a/prism/Services/HTMLCommentStripping.swift b/prism/Services/HTMLCommentStripping.swiftindex f1c4097..54ff207 100644--- a/prism/Services/HTMLCommentStripping.swift+++ b/prism/Services/HTMLCommentStripping.swift@@ -23,6 +23,14 @@ enum HTMLCommentStripping {     /// bodies), with the inner text captured as group 1. Shared with     /// ``MarkdownBlock`` and ``HTMLCommentExport`` so all `<!--…-->`-shape     /// scans use one source of truth and pay the regex-compile cost once.+    ///+    /// KNOWN QUADRATIC (T-2147): the lazy `[\s\S]*?` has the accumulate-and-+    /// rescan shape T-1951 removed from `HTMLImageParser` — on a run of `<!--`+    /// openers with no `-->`, every opener scans to the end of the input before+    /// failing, and `strip`'s `firstMatch` guard alone pays that cost. `strip`+    /// runs on whole raw-HTML blocks (MarkdownBlockParser's `.html` fallback),+    /// so this is reachable from a URL-opened document. The linear replacement+    /// pattern to follow lives at `HTMLImageParser.stripHTMLComments`.     nonisolated static let inlineCommentRegex: NSRegularExpression = {         // swiftlint:disable:next force_try         try! NSRegularExpression(pattern: #"<!--([\s\S]*?)-->"#)
prism/Services/HTMLImageParser.swift Modified +222 / -22 (incl. review fixes)
diff --git a/prism/Services/HTMLImageParser.swift b/prism/Services/HTMLImageParser.swiftindex 7ed1ad6..6b30b7a 100644--- a/prism/Services/HTMLImageParser.swift+++ b/prism/Services/HTMLImageParser.swift@@ -24,13 +24,18 @@ enum HTMLImageParser: Sendable {         let height: ImageDimension?     } -    // MARK: - Self-closing tag normalisation regex+    // MARK: - Self-closing tag normalisation -    // Matches void elements (<img>, <source>, <br>) without self-closing slashes.-    // swiftlint:disable:next force_try-    nonisolated private static let voidElementRegex = try! NSRegularExpression(-        pattern: #"(?i)<(img|source|br)(\s[^>]*?|)(?<!/)\s*>"#-    )+    /// The void elements normalised to XML self-closing form, matched case-insensitively.+    ///+    /// No word boundary follows the name: `<imgx>` is rejected by what must come *after* the+    /// name, not by the name match itself, which is what the retired regex did too.+    nonisolated private static let voidElementNames: [[unichar]] = ["img", "source", "br"]+        .map { Array($0.utf16) }++    nonisolated private static let openAngle = unichar(UInt8(ascii: "<"))+    nonisolated private static let closeAngle = unichar(UInt8(ascii: ">"))+    nonisolated private static let slash = unichar(UInt8(ascii: "/"))      // MARK: - Public API @@ -70,28 +75,222 @@ enum HTMLImageParser: Sendable {      // MARK: - Preprocessing -    // Matches HTML comments, including those spanning multiple lines.-    // swiftlint:disable:next force_try-    nonisolated private static let htmlCommentRegex = try! NSRegularExpression(-        pattern: #"<!--[\s\S]*?-->"#-    )+    nonisolated private static let commentOpen: [unichar] = Array("<!--".utf16)+    nonisolated private static let commentClose: [unichar] = Array("-->".utf16)++    /// Strips HTML comments (`<!-- ... -->`) from the input, including those spanning+    /// multiple lines.+    ///+    /// **Why this is a scan and not a regex (T-1951).** This ran as `<!--[\s\S]*?-->`+    /// replaced with the empty string, and had the same quadratic shape as the void-element+    /// regex below: the lazy `[\s\S]*?` grew to the end of the *whole input* from every+    /// `<!--` before failing when no `-->` follows, so a fragment of comment openers with no+    /// close measured 0.22 s at 8 KB and 2.3 s at 32 KB — ~4x per doubling. It runs first in+    /// `parse`, before the normalisation pass, so it too was reachable unconditionally from+    /// a URL-opened document.+    ///+    /// The scan reproduces the pattern exactly, established by the differential fuzz and+    /// goldens in `ImageParserCommentStrippingTests`. (The separate `HTMLCommentStripping`+    /// helper still carries the same regex shape on other call paths — tracked as T-2147,+    /// not silently widened into this fix.) Reading the pattern: a match at a `<!--`+    /// necessarily ends at the FIRST `-->` after it, because the lazy wildcard stops at the+    /// earliest position where `-->` matches, and matches are non-overlapping and leftmost.+    /// So each opener needs one forward lookup, the lookups arrive in increasing order, and+    /// once no `-->` remains ahead no later opener can match either — one pass answers+    /// everything.+    ///+    /// Internal rather than private so the differential fuzz can compare it against the+    /// retired regex directly, instead of through `parse`'s output.+    nonisolated static func stripHTMLComments(_ html: String) -> String {+        let nsHTML = html as NSString+        let length = nsHTML.length++        func matches(_ literal: [unichar], at position: Int) -> Bool {+            guard position + literal.count <= length else { return false }+            for (offset, unit) in literal.enumerated()+            where nsHTML.character(at: position + offset) != unit {+                return false+            }+            return true+        }++        var output = ""+        var copied = 0+        var index = 0+        while index < length {+            guard matches(Self.commentOpen, at: index) else {+                index += 1+                continue+            }+            // The first `-->` at or after the opener's end. If none exists, neither this+            // opener nor any later one can close, so the scan is done.+            var close = index + Self.commentOpen.count+            while close < length, !matches(Self.commentClose, at: close) { close += 1 }+            guard close < length else { break }+            output += nsHTML.substring(with: NSRange(location: copied, length: index - copied))+            copied = close + Self.commentClose.count+            index = copied+        } -    /// Strips HTML comments (`<!-- ... -->`) from the input.-    nonisolated private static func stripHTMLComments(_ html: String) -> String {-        let range = NSRange(html.startIndex..., in: html)-        return htmlCommentRegex.stringByReplacingMatches(in: html, range: range, withTemplate: "")+        guard copied > 0 else { return html }+        output += nsHTML.substring(from: copied)+        return output     }      /// Converts void elements without self-closing slashes to XML-compatible format.     ///     /// Replaces `<img ...>` with `<img ... />`, and similarly for `<source>` and `<br>`.-    nonisolated private static func normaliseSelfClosingTags(_ html: String) -> String {-        let range = NSRange(html.startIndex..., in: html)-        return voidElementRegex.stringByReplacingMatches(-            in: html,-            range: range,-            withTemplate: "<$1$2 />"-        )+    ///+    /// **Why this is a scan and not a regex (T-1951).** This ran as+    /// `(?i)<(img|source|br)(\s[^>]*?|)(?<!/)\s*>` replaced with `<$1$2 />`, and was+    /// quadratic on a fragment carrying many tag starts and no `>`: `[^>]*?` grew to the end+    /// of the *whole input* from every start before failing. A document of half-written+    /// `<img ` tags measured 0.64 s at 8 KB, 12.9 s at 35 KB and 35 s at 70 KB — and unlike+    /// `HTMLImageSourceRewriter`, this pass runs unconditionally at parse time on every raw+    /// HTML block up to 1 MB, so it stalled rendering before the rewriter was ever reached.+    /// Since any document can be opened from a URL, that was denial-of-service shaped. The+    /// same input now takes 0.45 ms and grows in step with the input rather than its square.+    ///+    /// The scan reproduces that pattern *exactly*. What establishes that is differential+    /// fuzzing against the pattern itself — 1.2 million generated fragments while the fix was+    /// written, 50,000 pinned in `VoidElementNormalisationTests` — and not the reading of it+    /// below, which explains the scan rather than being evidence for it. In particular the+    /// scan is deliberately **not** quote-aware: a `>` inside a quoted attribute value still+    /// ends the tag here, exactly as `[^>]` did, so `<img src="a>b">` is still mangled. That+    /// is a separate defect (T-1976) with its own observable behaviour change, kept out of a+    /// change whose whole warrant is that nothing else moves. One deliberate exception to+    /// exactness is documented at `asciiLowercased`: tag-name case folding is ASCII-only,+    /// where ICU's `(?i)` also folded U+017F onto `s`.+    ///+    /// Reading the pattern, a match at a `<` needs the tag name, then either+    /// `\s[^>]*?` or nothing, then `(?<!/)\s*>`. Two consequences drive the scan:+    ///+    /// - The closing `>` is necessarily the FIRST `>` at or after the name, because neither+    ///   `[^>]` nor `\s` can cross one. So each start needs one lookup — "the first `>` from+    ///   here" — and those lookups arrive in increasing order, so a cursor that only moves+    ///   forward answers all of them in one pass. Once no `>` remains, no start can match.+    /// - Lazy `[^>]*?` stops at the first position where the rest matches, so the group ends+    ///   at the start of the whitespace run leading up to that `>` — one character later when+    ///   the character before that run is a `/`, which is how `<img / >` normalises to+    ///   `<img /  />` while an already-closed `<img />` matches nothing at all.+    ///+    /// Internal rather than private so the differential fuzz can compare it against the+    /// retired regex directly, instead of through `parse`'s output.+    nonisolated static func normaliseSelfClosingTags(_ html: String) -> String {+        let nsHTML = html as NSString+        let length = nsHTML.length+        guard length > 2 else { return html }++        var output = ""+        var copied = 0+        var index = 0+        var closeCursor = -1+        var closesExhausted = false+        var cachedRunEnd = -1+        var cachedRunStart = -1++        /// The first `>` at or after `position`. Calls arrive with a non-decreasing+        /// `position`, so the underlying scan never revisits a unit and the whole sequence of+        /// lookups costs one pass over the input. The `max` below is defensive against that+        /// monotonicity ever breaking, not a live case: this line is only reached when+        /// `closeCursor < position`.+        func firstClose(atOrAfter position: Int) -> Int? {+            if closesExhausted { return nil }+            if closeCursor >= position { return closeCursor }+            var probe = max(position, closeCursor + 1)+            while probe < length, nsHTML.character(at: probe) != closeAngle { probe += 1 }+            guard probe < length else {+                closesExhausted = true+                return nil+            }+            closeCursor = probe+            return probe+        }++        /// Where the run of whitespace immediately before `end` begins (`end` itself when+        /// there is none). Cached for the last `end` asked about, because many tag starts can+        /// share one `>` — `<img<img<img…` followed by a long run — and re-walking that run+        /// per start would reintroduce the quadratic this scan exists to remove. One slot is+        /// enough: `end` is non-decreasing, and the runs for distinct `>` are disjoint.+        func whitespaceRunStart(endingAt end: Int) -> Int {+            if cachedRunEnd == end { return cachedRunStart }+            var start = end+            while start > 0, ICUWhitespace.contains(nsHTML.character(at: start - 1)) { start -= 1 }+            cachedRunEnd = end+            cachedRunStart = start+            return start+        }++        while index < length {+            guard nsHTML.character(at: index) == openAngle,+                  let nameLength = matchedVoidElementLength(in: nsHTML, at: index + 1),+                  let close = firstClose(atOrAfter: index + 1 + nameLength)+            else {+                index += 1+                continue+            }+            let afterName = index + 1 + nameLength++            // `\s[^>]*?` — needs whitespace first, then the lazy group ends at the earliest+            // position from which `(?<!/)\s*>` matches.+            var groupEnd: Int?+            if afterName < length, ICUWhitespace.contains(nsHTML.character(at: afterName)) {+                let runStart = max(afterName + 1, whitespaceRunStart(endingAt: close))+                if nsHTML.character(at: runStart - 1) != slash {+                    groupEnd = runStart+                } else if runStart < close {+                    // The lookbehind rejects ending the group after a `/`; taking one more+                    // character puts a whitespace unit there instead, which it accepts.+                    groupEnd = runStart + 1+                }+            }+            // The empty alternative — everything from the name to the `>` is whitespace. The+            // lookbehind always passes here: the preceding character is the name's last.+            if groupEnd == nil, whitespaceRunStart(endingAt: close) <= afterName {+                groupEnd = afterName+            }+            guard let groupEnd else {+                index += 1+                continue+            }++            output += nsHTML.substring(with: NSRange(location: copied, length: groupEnd - copied))+            output += " />"+            copied = close + 1+            index = close + 1+        }++        guard copied > 0 else { return html }+        output += nsHTML.substring(from: copied)+        return output+    }++    /// The length of the void element name written at `start`, or nil if none is.+    nonisolated private static func matchedVoidElementLength(in nsHTML: NSString, at start: Int) -> Int? {+        let length = nsHTML.length+        for name in voidElementNames where start + name.count <= length {+            var matched = true+            for (offset, unit) in name.enumerated()+            where asciiLowercased(nsHTML.character(at: start + offset)) != unit {+                matched = false+                break+            }+            if matched { return name.count }+        }+        return nil+    }++    /// ASCII-lowercases a UTF-16 unit.+    ///+    /// This is a known, deliberate divergence from the retired regex's `(?i)`: ICU also+    /// folds U+017F (LATIN SMALL LETTER LONG S) onto `s`, so the old pattern normalised+    /// `<ſource>` while this scan leaves it alone. ASCII-only is what HTML5 itself specifies+    /// for tag-name matching, so the new behaviour is kept and pinned by a golden in+    /// `VoidElementNormalisationTests` rather than reproduced. U+017F is the only such code+    /// point for these names — ICU's simple folds from non-ASCII onto ASCII letters are+    /// ſ→s and U+212A (KELVIN SIGN)→k, and no name here contains a `k`.+    nonisolated private static func asciiLowercased(_ unit: unichar) -> unichar {+        (unit >= 65 && unit <= 90) ? unit + 32 : unit     }      // MARK: - XML Parsing
prism/Services/ICUWhitespace.swift Added +38 / -0
diff --git a/prism/Services/ICUWhitespace.swift b/prism/Services/ICUWhitespace.swiftnew file mode 100644index 0000000..ac5ef7d--- /dev/null+++ b/prism/Services/ICUWhitespace.swift@@ -0,0 +1,38 @@+//+//  ICUWhitespace.swift+//  prism+//+//  The whitespace class ICU's regex engine means by `\s`, as a predicate over UTF-16 units.+//+//  Several scans over raw HTML replaced an `NSRegularExpression` with a hand-written linear+//  pass (T-1655, T-1951). Each of those regexes spelled whitespace `\s`, so each replacement+//  is only byte-identical to the pattern it retired if it agrees with ICU on exactly which+//  code points are whitespace — which is a genuinely surprising set, and not one any single+//  Foundation `CharacterSet` spells. Getting it wrong shifts a tag boundary rather than+//  failing loudly, so the definition lives in one place with the evidence attached rather+//  than being re-derived per call site.+//+//  ICU documents `\s` as `[\t\n\f\r\p{Z}]`. Probed against `NSRegularExpression` over every+//  plausible whitespace code point, that set turns out to equal+//  `CharacterSet.whitespacesAndNewlines` minus U+200B (ZERO WIDTH SPACE) — Foundation counts+//  U+200B as whitespace, ICU does not, and the two agree on every other code point tested,+//  including U+000B, U+0085, U+2028, U+2029, U+180E, and U+FEFF.+//++import Foundation++nonisolated enum ICUWhitespace {++    /// The code points ICU's `\s` matches.+    static let characterSet: CharacterSet = CharacterSet.whitespacesAndNewlines+        .subtracting(CharacterSet(charactersIn: "\u{200B}"))++    /// Whether a UTF-16 unit is one of them.+    ///+    /// Every member is in the BMP, so a lone surrogate — which cannot be a scalar at all —+    /// is not whitespace, and a scan may test units without first combining pairs.+    static func contains(_ unit: unichar) -> Bool {+        guard let scalar = Unicode.Scalar(UInt32(unit)) else { return false }+        return characterSet.contains(scalar)+    }+}
prism/Services/WebRendering/HTMLImageSourceRewriter.swift Modified +43 / -14
diff --git a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift b/prism/Services/WebRendering/HTMLImageSourceRewriter.swiftindex 185cd59..2276c36 100644--- a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift+++ b/prism/Services/WebRendering/HTMLImageSourceRewriter.swift@@ -40,13 +40,6 @@ nonisolated enum HTMLImageSourceRewriter {     /// The attribute names carrying an image reference that must be mediated.     private static let mediatedAttributes: Set<String> = ["src", "srcset"] -    /// Whitespace as ICU's `\s` defines it — `CharacterSet.whitespacesAndNewlines` agrees on-    /// every code point except U+200B (ZERO WIDTH SPACE), which ICU does not treat as-    /// whitespace. Subtracting it keeps the scan below byte-identical to the regex it-    /// replaced.-    private static let attributeWhitespace: CharacterSet = CharacterSet.whitespacesAndNewlines-        .subtracting(CharacterSet(charactersIn: "\u{200B}"))-     private static let equalsSign = unichar(UInt8(ascii: "="))     private static let quotes: Set<unichar> = Set("\"'".utf16)     /// Characters that end an attribute name, besides whitespace.@@ -263,9 +256,11 @@ nonisolated enum HTMLImageSourceRewriter {         !unquotedValueTerminators.contains(unit) && !isWhitespaceUnit(unit)     } +    /// Whitespace as ICU's `\s` defines it, which is what keeps this scan byte-identical to+    /// the regex it replaced. Shared with the void-element scan, which retired a regex+    /// spelling whitespace the same way (T-1951).     private static func isWhitespaceUnit(_ unit: unichar) -> Bool {-        guard let scalar = Unicode.Scalar(UInt32(unit)) else { return false }  // Lone surrogate.-        return attributeWhitespace.contains(scalar)+        ICUWhitespace.contains(unit)     }      /// Rewrites every candidate in a `srcset` value, preserving descriptors (`1x`, `480w`).@@ -290,25 +285,59 @@ nonisolated enum HTMLImageSourceRewriter {      /// Splits a `srcset` into candidate strings, treating a comma inside a `data:` URL's     /// payload (before its descriptor whitespace) as part of the URL, not a separator.-    private static func splitSrcsetCandidates(_ srcset: String) -> [String] {+    ///+    /// **Why the `data:` test is kept on a short probe (T-1951).** Asking whether the+    /// candidate so far starts with `data:` used to be spelled+    /// `current.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("data:")`, which+    /// built two whole copies of the candidate per character to inspect five characters of+    /// it: 1.4 s for a 31 KB attribute value, 2.8 s at 62 KB. A `srcset` that long is an+    /// ordinary thing to write into a document, and one opened from a URL made that someone+    /// else's decision, so the cost is not merely academic. The same value now takes 1.4 ms+    /// and grows in step with its length.+    ///+    /// `probe` is that same test's input, kept to the candidate's leading characters. The+    /// verdict can only turn on the first five characters after any leading whitespace, and+    /// it is asked only until it is settled — either `data:` has been found, or six+    /// characters have gone by without it, after which no later character can bring it back.+    /// Trailing whitespace is not trimmed from the probe because trimming it cannot change a+    /// five-character prefix. Equivalence with the original expression rests on differential+    /// fuzzing against it — 600,000 generated values while the fix was written, 50,000 pinned+    /// in `SrcsetCandidateSplitTests` — rather than on that argument.+    ///+    /// Internal rather than private so that fuzzing can compare it against the expression it+    /// replaced directly, instead of through the joined-up rewrite output, which cannot+    /// distinguish every split.+    static func splitSrcsetCandidates(_ srcset: String) -> [String] {         var candidates: [String] = []         var current = ""         var inDataURL = false         var sawWhitespaceInData = false+        var probe = ""+        var probeSettled = false         for character in srcset {             if character == "," && !(inDataURL && !sawWhitespaceInData) {                 candidates.append(current)                 current = ""                 inDataURL = false                 sawWhitespaceInData = false+                probe = ""+                probeSettled = false                 continue             }             current.append(character)-            if !inDataURL, current.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("data:") {-                inDataURL = true+            if !inDataURL, !probeSettled,+               !probe.isEmpty || !String(character).trimmingCharacters(in: .whitespaces).isEmpty {+                probe.append(character)+                if probe.lowercased().hasPrefix("data:") {+                    inDataURL = true+                } else if probe.count > 5 {+                    probeSettled = true+                }             }-            if inDataURL, character == " " || character == "\t",-               !current.trimmingCharacters(in: .whitespaces).isEmpty {+            // Reaching here with `inDataURL` set means the candidate holds `data:`, so it is+            // never whitespace-only — which is what the emptiness test used to re-derive with+            // another full copy of the candidate on every space of a `data:` payload.+            if inDataURL, character == " " || character == "\t" {                 sawWhitespaceInData = true             }         }
prismTests/RawHTMLImageScanGrowthTests.swift Added +465 / -0
diff --git a/prismTests/RawHTMLImageScanGrowthTests.swift b/prismTests/RawHTMLImageScanGrowthTests.swiftnew file mode 100644index 0000000..240c550--- /dev/null+++ b/prismTests/RawHTMLImageScanGrowthTests.swift@@ -0,0 +1,465 @@+//+//  RawHTMLImageScanGrowthTests.swift+//  prismTests+//+//  Growth + equivalence guards for the last quadratic scans on the raw-HTML image path+//  (T-1951): `HTMLImageParser`'s void-element normalisation and HTML comment stripping, and+//  `HTMLImageSourceRewriter.splitSrcsetCandidates`. Comment stripping joined in review of+//  this very fix: it had the identical lazy-wildcard shape and sat first in the same+//  unconditional call path.+//+//  All were accumulate-and-rescan shapes — the recurring fault in this area (T-1655, T-1877,+//  T-1966): work already done is re-derived because the scan cursor does not advance, or+//  because a growing accumulator is re-examined from the start on every character. All are+//  reachable from a document opened by URL, so all were denial-of-service shaped rather than+//  merely slow.+//+//  Two kinds of assertion, both needed:+//+//  - GROWTH: measured at N and 4N with a ratio ceiling, never an absolute budget at one size.+//    An absolute budget cannot separate a linear pass from a quadratic one — it only records+//    what one machine did on one day — and absolute budgets in this project are documented as+//    flaky under concurrent `xcodebuild` load (T-1541). See `GrowthRatioGuard`. The two+//    guards T-1655 added in `BlockHTMLEmitterMediaTests` are absolute for exactly the reason+//    named in their own comments; this file is the shape they pointed at.+//  - EQUIVALENCE: the replacements are pure performance work, so the bar is that NOTHING+//    else moves. Each is differentially fuzzed against the exact expression it replaced,+//    kept here as the oracle, plus goldens for the shapes whose behaviour is surprising+//    enough that a reader would otherwise assume a fuzz mismatch was the correct answer.+//+//  The suites are `.serialized`: they are timing measurements, and running them concurrently+//  puts the base and the 4x measurement under different contention — the very noise the ratio+//  exists to cancel.+//++import Foundation+import Testing+@testable import prism++// MARK: - Void-element normalisation (HTMLImageParser)++@Suite("Void-element normalisation — growth and equivalence (T-1951)", .serialized)+struct VoidElementNormalisationTests {++    // The retired implementation, kept verbatim as the differential oracle.+    //+    // `[^>]*?` grows to the end of the WHOLE input from every tag start before failing when+    // no `>` follows, so a document of half-written `<img ` tags cost time in proportion to+    // its square: 0.64 s at 8 KB, 2.3 s at 17 KB, 12.9 s at 35 KB, 35 s at 70 KB. This runs+    // unconditionally at parse time on every raw-HTML block up to 1 MB.+    // swiftlint:disable:next force_try+    private static let retiredRegex = try! NSRegularExpression(+        pattern: #"(?i)<(img|source|br)(\s[^>]*?|)(?<!/)\s*>"#+    )++    private static func retiredNormalise(_ html: String) -> String {+        retiredRegex.stringByReplacingMatches(+            in: html,+            range: NSRange(html.startIndex..., in: html),+            withTemplate: "<$1$2 />"+        )+    }++    // MARK: Growth++    @Test("G1: half-written <img tags normalise in time proportional to the document")+    func halfWrittenTagsScaleLinearly() {+        // The shape T-1951 was filed for. 0.64s at 8 KB rising to 35s at 70 KB before the+        // fix — a ~4x cost for each doubling, and a stalled render for anyone who opened+        // such a document. 0.45ms at 70 KB after it.+        GrowthRatioGuard.expectLinearGrowth(shape: "half-written <img tags", baseCount: 400) { count in+            let unit = "<img " + String(repeating: "a", count: 40)+            _ = HTMLImageParser.normaliseSelfClosingTags(String(repeating: unit, count: count))+        }+    }++    @Test("G2: tag starts with no closing bracket anywhere normalise linearly")+    func unclosedTagStartsScaleLinearly() {+        // The companion shape, and the one the T-1655 guard in BlockHTMLEmitterMediaTests+        // uses. That guard's input has no space after `<img`, which fails the `\s[^>]*?`+        // branch immediately and so never reached this scan at all — the false confidence+        // its own comment warns about. Both spellings are pinned here.+        GrowthRatioGuard.expectLinearGrowth(shape: "unclosed <img starts", baseCount: 4_000) { count in+            _ = HTMLImageParser.normaliseSelfClosingTags(String(repeating: "<img", count: count))+        }+    }++    @Test("G3: many tag starts sharing one distant closing bracket normalise linearly")+    func sharedDistantCloseScalesLinearly() {+        // Guards the whitespace-run cache specifically. Every start here resolves to the SAME+        // `>`, behind the same long run of whitespace; re-walking that run once per start+        // would be quadratic again even though the `>` lookup itself is a forward-only+        // cursor. This shape was never slow before the fix — it is a guard against the+        // obvious way to get the fix wrong.+        GrowthRatioGuard.expectLinearGrowth(shape: "starts sharing one distant `>`", baseCount: 2_000) { count in+            let html = String(repeating: "<img", count: count)+                + String(repeating: " ", count: count * 4) + ">"+            _ = HTMLImageParser.normaliseSelfClosingTags(html)+        }+    }++    @Test("G4: the parse entry point itself no longer stalls on half-written tags")+    func parseEntryPointScalesLinearly() {+        // The growth guards above call the scan directly. This one goes through the public+        // entry point, which is what a document actually reaches, so the fix is pinned where+        // the denial of service lived rather than only where it was written.+        GrowthRatioGuard.expectLinearGrowth(shape: "HTMLImageParser.parse of half-written tags",+                                            baseCount: 400) { count in+            let unit = "<img " + String(repeating: "a", count: 40)+            _ = HTMLImageParser.parse(String(repeating: unit, count: count))+        }+    }++    // MARK: Equivalence — goldens++    @Test("Void elements normalise exactly as the retired regex did")+    func goldenShapes() {+        let cases: [(input: String, expected: String)] = [+            ("<img>", "<img />"),+            ("<br>", "<br />"),+            ("<source>", "<source />"),+            ("<IMG SRC=x>", "<IMG SRC=x />"),+            ("<img src=\"a.png\">", "<img src=\"a.png\" />"),+            // Trailing whitespace before the `>` is absorbed by `\s*`, not by the group.+            ("<img src=\"a.png\"   >", "<img src=\"a.png\" />"),+            ("<img  >", "<img  />"),+            ("<img\n>", "<img\n />"),+            // Already self-closing: the lookbehind rejects both alternatives, no match.+            ("<img />", "<img />"),+            ("<img/>", "<img/>"),+            ("<br/>", "<br/>"),+            // A `/` before the trailing whitespace run is stepped over by one character,+            // which is the lazy group's next viable end — not skipped, and not a non-match.+            // The group therefore ends up holding the space that followed the `/`, and the+            // emitted ` />` adds its own: two spaces, not one.+            ("<img / >", "<img /  />"),+            // Not a void element: nothing after the name can start the group.+            ("<imgx>", "<imgx>"),+            ("<image>", "<image>"),+            // No `>` at all — the case that used to cost the most.+            ("<img ", "<img "),+            ("<img src=\"a\"", "<img src=\"a\""),+            // A tag start inside another tag's text is consumed by the outer match, exactly+            // as non-overlapping regex matching did.+            ("<img <img>", "<img <img />"),+            ("<img<img>", "<img<img />"),+            ("", ""),+            ("no tags here", "no tags here")+        ]+        for (input, expected) in cases {+            #expect(HTMLImageParser.normaliseSelfClosingTags(input) == expected,+                    Comment(rawValue: "\(input.debugDescription) normalised to "+                        + HTMLImageParser.normaliseSelfClosingTags(input).debugDescription))+            #expect(Self.retiredNormalise(input) == expected,+                    "oracle disagrees on \(input.debugDescription) — the golden is wrong")+        }+    }++    @Test("A `>` inside a quoted attribute value still ends the tag (T-1976 preserved)")+    func quotedCloseBracketStillMangles() {+        // NOT a fix. `[^>]` could not see quoting either, so a quote-aware scan would be a+        // behaviour change smuggled into a performance change — and one that alters what+        // reaches the XML parser for every document with such a tag. The defect keeps its+        // own ticket; this test states that leaving it standing was deliberate, and fails+        // loudly if a later change to the scan closes it silently.+        let input = "<img src=\"a>b\">"+        #expect(HTMLImageParser.normaliseSelfClosingTags(input) == "<img src=\"a />b\">")+        #expect(Self.retiredNormalise(input) == HTMLImageParser.normaliseSelfClosingTags(input))+    }++    @Test("Tag-name matching is ASCII-case-insensitive only — a deliberate divergence")+    func longSDoesNotMatchSource() {+        // The one known, deliberate divergence from the retired regex. ICU's `(?i)` folds+        // U+017F (LATIN SMALL LETTER LONG S) onto `s`, so the old pattern normalised+        // `<ſource>` to `<ſource />`. The scan matches tag names ASCII-only — which is what+        // HTML5 itself specifies for tag names — so `<ſource>` now passes through untouched.+        // This golden pins the NEW behaviour, deliberately without the oracle agreement the+        // other goldens assert: the oracle disagrees here, and that is the point. It is also+        // why `ſ` must stay out of the fuzz alphabets above — a genuine divergence would+        // read as a fuzz mismatch. U+017F is the only such code point for these names: ICU's+        // simple folds from non-ASCII onto ASCII letters are ſ→s and U+212A (KELVIN SIGN)→k,+        // and no name here contains a `k`.+        #expect(HTMLImageParser.normaliseSelfClosingTags("<ſource>") == "<ſource>")+        #expect(Self.retiredNormalise("<ſource>") == "<ſource />",+                "the retired regex stopped folding ſ onto s — this divergence note is stale")+    }++    // MARK: Equivalence — differential fuzz++    @Test("The scan reproduces the retired regex on random fragments")+    func fuzzRandomFragments() {+        // Deliberately hostile alphabet: the units that decide a match (`<`, `>`, `/`, the+        // tag names in both cases, every flavour of whitespace) at high density, so a+        // generated fragment is far more likely to sit on a boundary case than real markup+        // would be. U+200B is in the alphabet because it is the one code point where+        // Foundation and ICU disagree about whitespace, and U+0301 because a combining mark+        // after a `>` makes it part of a composed character sequence — which is how the+        // first draft of this scan went wrong, by looking for `>` with `NSString.range(of:)`+        // instead of scanning UTF-16 units.+        let alphabet = ["<", ">", "/", "i", "m", "g", "s", "o", "u", "r", "c", "e", "b",+                        "I", "M", "G", "B", "R", "S", " ", " ", " ", "\t", "\n", "\"", "'",+                        "=", "a", "x", "\u{00A0}", "\u{200B}", "\u{2028}", "\u{0301}",+                        "<img", "<img ", "<source ", "<br", " />", "/>", "src=\"a\""]+        expectFuzzAgreement(seed: 0x5195_1A, alphabet: alphabet, maxUnits: 40, rounds: 25_000)+    }++    @Test("The scan reproduces the retired regex on generated tag-shaped fragments")+    func fuzzTagShapedFragments() {+        let alphabet = ["<img", "<source", "<br", "<IMG", "<Br", " ", "  ", "\t", "\n", ">",+                        "/>", " />", "/", " src=\"a.png\"", " srcset='a 1x, b 2x'", " alt=x",+                        "=", "\"", "'", "<p>", "</p>", "text", "<!--", "-->",+                        "<img src=\"a>b\">", "\u{00A0}", "\u{200B}"]+        expectFuzzAgreement(seed: 0x1976_1A, alphabet: alphabet, maxUnits: 60, rounds: 25_000)+    }++    /// Fails on the first disagreement, reporting the input verbatim — a fuzz failure is only+    /// useful if it hands back the case that broke it.+    private func expectFuzzAgreement(seed: UInt64, alphabet: [String], maxUnits: Int, rounds: Int) {+        var rng = SeededRandomNumberGenerator(seed: seed)+        for _ in 0..<rounds {+            let units = Int.random(in: 0...maxUnits, using: &rng)+            var input = ""+            for _ in 0..<units { input += alphabet.randomElement(using: &rng) ?? "a" }+            let expected = Self.retiredNormalise(input)+            let actual = HTMLImageParser.normaliseSelfClosingTags(input)+            guard expected == actual else {+                Issue.record(Comment(rawValue: "scan diverged from the retired regex on"+                    + " \(input.debugDescription): expected \(expected.debugDescription),"+                    + " got \(actual.debugDescription)"))+                return+            }+        }+    }+}++// MARK: - HTML comment stripping (HTMLImageParser)++// Named for its subject — `HTMLImageParser.stripHTMLComments` — because the suite name+// `HTMLCommentStrippingTests` is taken by the tests for the separate `HTMLCommentStripping`+// helper, whose own regex still carries this shape (tracked as T-2147).+@Suite("Image-parser comment stripping — growth and equivalence (T-1951)", .serialized)+struct ImageParserCommentStrippingTests {++    // The retired implementation, kept verbatim as the differential oracle.+    //+    // `[\s\S]*?` grew to the end of the WHOLE input from every `<!--` before failing when no+    // `-->` follows — the identical shape to the void-element regex above, in the same+    // unconditional call path, one line earlier in `parse`. Found in review of the T-1951+    // fix rather than by the ticket's tally: 0.22 s at 8 KB, 2.3 s at 32 KB, ~4x per+    // doubling.+    // swiftlint:disable:next force_try+    private static let retiredRegex = try! NSRegularExpression(pattern: #"<!--[\s\S]*?-->"#)++    private static func retiredStrip(_ html: String) -> String {+        retiredRegex.stringByReplacingMatches(+            in: html,+            range: NSRange(html.startIndex..., in: html),+            withTemplate: ""+        )+    }++    // MARK: Growth++    @Test("G8: comment openers with no close anywhere strip linearly")+    func unclosedOpenersScaleLinearly() {+        // The shape the review measured: a run of `<!--` with no `-->` anywhere, which is+        // what a truncated or half-written document looks like.+        GrowthRatioGuard.expectLinearGrowth(shape: "unclosed <!-- openers", baseCount: 4_000) { count in+            _ = HTMLImageParser.stripHTMLComments(String(repeating: "<!--", count: count))+        }+    }++    @Test("G9: the parse entry point no longer stalls on unclosed comment openers")+    func parseEntryPointScalesLinearly() {+        // Through the public entry point, where a document reaches it — comment stripping is+        // the very first step of `parse`, before the void-element normalisation.+        GrowthRatioGuard.expectLinearGrowth(shape: "HTMLImageParser.parse of unclosed <!-- openers",+                                            baseCount: 4_000) { count in+            _ = HTMLImageParser.parse(String(repeating: "<!--", count: count))+        }+    }++    // MARK: Equivalence — goldens++    @Test("Comments strip exactly as the retired regex did")+    func goldenShapes() {+        let cases: [(input: String, expected: String)] = [+            ("<!-- a comment -->", ""),+            ("before <!-- a --> after", "before  after"),+            ("<!-- line\nbreak -->", ""),+            // The shortest possible comment.+            ("<!---->", ""),+            // One dash short of closing: `->` cannot complete `-->`.+            ("<!--->", "<!--->"),+            // Extra dashes belong to the wildcard until the first viable `-->`.+            ("<!----->", ""),+            // Lazy: the match ends at the FIRST `-->`, so the second survives.+            ("<!-- a --> b -->", " b -->"),+            // An opener inside a comment is consumed by the outer match, exactly as+            // non-overlapping regex matching did.+            ("<!-- x <!-- y -->", ""),+            // A comment splitting a tag leaves the halves adjacent.+            ("<img <!-- c --> src=x>", "<img  src=x>"),+            ("a<!---->b<!---->c", "abc"),+            // No close anywhere — the case that used to cost the most.+            ("<!-- never closed", "<!-- never closed"),+            ("no opener -->", "no opener -->"),+            ("", ""),+            ("no comments here", "no comments here")+        ]+        for (input, expected) in cases {+            #expect(HTMLImageParser.stripHTMLComments(input) == expected,+                    Comment(rawValue: "\(input.debugDescription) stripped to "+                        + HTMLImageParser.stripHTMLComments(input).debugDescription))+            #expect(Self.retiredStrip(input) == expected,+                    "oracle disagrees on \(input.debugDescription) — the golden is wrong")+        }+    }++    // MARK: Equivalence — differential fuzz++    @Test("The scan reproduces the retired regex on random fragments")+    func fuzzRandomFragments() {+        // The units that decide a match at high density: partial and complete openers and+        // closers, stray dashes and angle brackets, so a generated fragment is far more+        // likely than real markup to sit on a boundary — an opener whose closer is+        // truncated, a closer with no opener, dash runs straddling both.+        let alphabet = ["<!--", "-->", "<!", "--", "-", "<", ">", "!", "a", " ", "\n", "\t",+                        "<!---->", "<!--->", "<img>", "text"]+        var rng = SeededRandomNumberGenerator(seed: 0x5195_1C)+        for _ in 0..<25_000 {+            let units = Int.random(in: 0...40, using: &rng)+            var input = ""+            for _ in 0..<units { input += alphabet.randomElement(using: &rng) ?? "a" }+            let expected = Self.retiredStrip(input)+            let actual = HTMLImageParser.stripHTMLComments(input)+            guard expected == actual else {+                Issue.record(Comment(rawValue: "scan diverged from the retired regex on"+                    + " \(input.debugDescription): expected \(expected.debugDescription),"+                    + " got \(actual.debugDescription)"))+                return+            }+        }+    }+}++// MARK: - srcset candidate splitting (HTMLImageSourceRewriter)++@Suite("srcset candidate splitting — growth and equivalence (T-1951)", .serialized)+struct SrcsetCandidateSplitTests {++    /// The retired implementation, kept verbatim as the differential oracle. It built two+    /// whole copies of the candidate accumulated so far on every character, to look at five+    /// characters of it: 0.03 s for a 7 KB value, 1.4 s at 31 KB, 2.8 s at 62 KB.+    private static func retiredSplit(_ srcset: String) -> [String] {+        var candidates: [String] = []+        var current = ""+        var inDataURL = false+        var sawWhitespaceInData = false+        for character in srcset {+            if character == "," && !(inDataURL && !sawWhitespaceInData) {+                candidates.append(current)+                current = ""+                inDataURL = false+                sawWhitespaceInData = false+                continue+            }+            current.append(character)+            if !inDataURL, current.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("data:") {+                inDataURL = true+            }+            if inDataURL, character == " " || character == "\t",+               !current.trimmingCharacters(in: .whitespaces).isEmpty {+                sawWhitespaceInData = true+            }+        }+        if !current.trimmingCharacters(in: .whitespaces).isEmpty {+            candidates.append(current)+        }+        return candidates+    }++    // MARK: Growth++    @Test("G5: a long candidate splits in time proportional to its length")+    func longCandidateScalesLinearly() {+        GrowthRatioGuard.expectLinearGrowth(shape: "one long srcset candidate", baseCount: 2_000) { count in+            _ = HTMLImageSourceRewriter.splitSrcsetCandidates(String(repeating: "abcdefgh", count: count))+        }+    }++    @Test("G6: a data: candidate carrying whitespace splits linearly")+    func dataCandidateWithWhitespaceScalesLinearly() {+        // The second full copy per character, taken on every space of a `data:` payload to+        // re-establish that the candidate is not whitespace-only — which it cannot be, since+        // it holds `data:`.+        GrowthRatioGuard.expectLinearGrowth(shape: "data: srcset candidate with spaces",+                                            baseCount: 2_000) { count in+            let value = "data:image/png;base64," + String(repeating: "A ", count: count)+            _ = HTMLImageSourceRewriter.splitSrcsetCandidates(value)+        }+    }++    @Test("G7: a long srcset survives the whole rewrite in linear time")+    func rewriteOfLongSrcsetScalesLinearly() {+        // Through the public entry point, where a document reaches it.+        GrowthRatioGuard.expectLinearGrowth(shape: "rewrite of a long srcset", baseCount: 1_000) { count in+            let raw = "<img srcset=\"" + String(repeating: "abcdefgh", count: count) + " 1x\">"+            _ = HTMLImageSourceRewriter.rewrite(raw) { $0 }+        }+    }++    // MARK: Equivalence — goldens++    @Test("srcset candidates split exactly as the retired expression did")+    func goldenShapes() {+        let cases: [(input: String, expected: [String])] = [+            ("a.png 1x, b.png 2x", ["a.png 1x", " b.png 2x"]),+            ("a.png,b.png", ["a.png", "b.png"]),+            // A comma inside a data: payload is part of the URL until the descriptor space.+            ("data:image/png;base64,AAA= 1x", ["data:image/png;base64,AAA= 1x"]),+            ("data:image/png;base64,AAA= 1x, b.png 2x",+             ["data:image/png;base64,AAA= 1x", " b.png 2x"]),+            ("  data:image/png;base64,AAA=", ["  data:image/png;base64,AAA="]),+            ("DATA:image/png;base64,AAA=", ["DATA:image/png;base64,AAA="]),+            // Not a data: URL: the prefix must be the start of the candidate.+            ("x-data:image/png;base64,AAA=", ["x-data:image/png;base64", "AAA="]),+            ("", []),+            ("   ", []),+            (",", [""]),+            ("a.png,", ["a.png"])+        ]+        for (input, expected) in cases {+            #expect(HTMLImageSourceRewriter.splitSrcsetCandidates(input) == expected,+                    Comment(rawValue: "\(input.debugDescription) split to "+                        + "\(HTMLImageSourceRewriter.splitSrcsetCandidates(input))"))+            #expect(Self.retiredSplit(input) == expected,+                    "oracle disagrees on \(input.debugDescription) — the golden is wrong")+        }+    }++    // MARK: Equivalence — differential fuzz++    @Test("The split reproduces the retired expression on generated values")+    func fuzzGeneratedValues() {+        // `İ` and `Σ`/`ς` are in the alphabet because lowercasing is context-sensitive and+        // can change a string's length, which is the only way examining a bounded prefix+        // could have answered differently from examining the whole accumulated candidate.+        let alphabet = [",", " ", " ", "\t", "d", "a", "t", ":", "D", "A", "T", "x", "1",+                        "/", ";", "=", "data:", "data:image/png;base64,AAA=", " 1x",+                        "\u{00A0}", "\u{200B}", "\u{0301}", "İ", "Σ", "ς", "\n", "480w"]+        var rng = SeededRandomNumberGenerator(seed: 0x5195_1B)+        for _ in 0..<50_000 {+            let units = Int.random(in: 0...30, using: &rng)+            var input = ""+            for _ in 0..<units { input += alphabet.randomElement(using: &rng) ?? "a" }+            let expected = Self.retiredSplit(input)+            let actual = HTMLImageSourceRewriter.splitSrcsetCandidates(input)+            guard expected == actual else {+                Issue.record(Comment(rawValue: "split diverged from the retired expression on"+                    + " \(input.debugDescription): expected \(expected), got \(actual)"))+                return+            }+        }+    }+}
prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift Modified +11 / -9
diff --git a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swiftindex a64ee92..6064b03 100644--- a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift+++ b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift@@ -292,8 +292,10 @@ struct RawHTMLImageRewriteTests {         //         // Note what this asserts: ONE absolute budget at ONE input size, which catches the         // known blow-up (and any comparable constant-factor regression) but cannot by itself-        // establish a complexity class. Measuring at N and 2N with a ratio ceiling would;-        // T-1951 carries that for the guards added alongside its own fixes.+        // establish a complexity class. Measuring at N and 4N with a ratio ceiling does, and+        // that is now the shared `GrowthRatioGuard` (T-1966). This guard is kept as-is because+        // its input is the exact case that was reported: pinning it at its reported size is+        // worth more than restating a complexity class guarded elsewhere.         //         // The budget is deliberately ~4 orders of magnitude above the real cost so a loaded         // machine cannot make it flake, while still being 4x below the pre-fix time.@@ -314,13 +316,13 @@ struct RawHTMLImageRewriteTests {         // quadratic). Once no `>` remains, no later start can match either, so the scan stops.         //         // Scope trap — do NOT read this guard as broader coverage than it has. The input has-        // no space after `<img`, which fails `HTMLImageParser.voidElementRegex`'s `\s[^>]*?`-        // branch immediately, so this exercises HTMLImageSourceRewriter ONLY. That regex runs-        // unconditionally at parse time on every HTML block, BEFORE this rewriter is reached,-        // and is itself quadratic: `String(repeating: "<img " + String(repeating: "a", 40))`-        // measures 0.15s at 9 KB rising to 11.42s at 72 KB. It is pre-existing on main, out-        // of scope for T-1655, and tracked in T-1951 — along with `splitSrcsetCandidates` in-        // the rewriter itself (1.98s at 128 KB), which this PR does not touch.+        // no space after `<img`, which fails the void-element normalisation's `\s[^>]*?`+        // branch immediately, so this exercises HTMLImageSourceRewriter ONLY. That+        // normalisation runs unconditionally at parse time on every HTML block, BEFORE this+        // rewriter is reached, and was quadratic in the same way on the spelling WITH the+        // space; so was `splitSrcsetCandidates`, in this very file. Both were fixed in T-1951+        // and are guarded by growth ratios in `RawHTMLImageScanGrowthTests`, which pins the+        // spelling with the space alongside this one.         //         // As above, this asserts one absolute budget at one input size, not a complexity class.         let raw = String(repeating: "<img", count: 16_000)  // 64 KB of starts, no `>` at all.

Things to double-check

CI is billing-blocked — validation is local only.

GitHub Actions is blocked at the account level, so nothing on this PR has run in CI. Locally green in this review: make lint and the four targeted suites (VoidElementNormalisationTests, ImageParserCommentStrippingTests, SrcsetCandidateSplitTests, RawHTMLImageRewriteTests) after the comment-only fixes; the author reports the broader targeted run 98/98 green. A full make test/make test-ui pass has not been run in this session.

The working-tree fixes are uncommitted.

Two comment-only edits to HTMLImageParser.swift (+9/−1) sit in the working tree. Commit them (e.g. amend or a small review-fix commit) before pushing, or the review round they answer will look unaddressed.

U+017F renders differently for any real document using it.

Vanishingly unlikely in practice (<ſource>), but it is the one input whose rendering this PR changes. The CHANGELOG discloses it; nothing further needed.