perf(sanitize): make clean text allocation-free on the hot path - #3120
Merged
Conversation
Sanitizing user-authored response fields ran multiple allocating passes over every string regardless of content: FilterInvisibleCharacters converted the whole input to []rune and back, FilterCodeFenceMetadata split and rejoined every line, and bluemonday ran unconditionally. On comment- and issue-heavy responses this dominated conversion CPU and allocation. Three changes, none of which alter output or widen what the policy allows: - FilterInvisibleCharacters scans first and copies only from the first filtered rune, skipping ASCII runs without decoding them. Invalid UTF-8 is still re-encoded to U+FFFD, matching the []rune round trip it replaces. - FilterCodeFenceMetadata walks lines in place and returns the input when no line changes. - FilterHTMLTags skips bluemonday for input that is provably a fixed point of the policy: printable ASCII, TAB and LF, with none of the five characters html.EscapeString rewrites. Sanitize also skips the second invisible/code-fence pass when HTML normalization returned its input unchanged, since both filters are fixed points there. Equivalence is pinned by a verbatim copy of the previous pipeline: the new code is diffed against it over a corpus of ~22k deterministic cases plus two fuzz targets, and the fast path is checked byte by byte against the live bluemonday policy. Benchmarks (Intel Ultra 9 185H, n=6): Sanitize/TitleASCII 5.35µs -> 114ns 1 -100% allocs Sanitize/Comment1KiB 45.3µs -> 1.27µs 1 -100% allocs Sanitize/Body64KiB 2.47ms -> 85.9µs 1 -100% allocs 30 issues x 2KiB body 3.00ms -> 88.6µs 1.55MiB -> 1.9KiB 100 comments x 1KiB 5.04ms -> 169µs 2.19MiB -> 6.3KiB Content that genuinely needs rewriting still pays for it, and non-ASCII text still goes through bluemonday by design. Fixes #3117 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Optimizes sanitization hot paths while preserving existing filtering behavior.
Changes:
- Adds allocation-free fast paths for clean text and inert HTML.
- Avoids redundant sanitizer passes and code-fence reconstruction.
- Adds equivalence, fuzz, allocation, and performance coverage.
Show a summary per file
| File | Description |
|---|---|
pkg/sanitize/sanitize.go |
Implements sanitizer fast paths. |
pkg/sanitize/equivalence_test.go |
Verifies behavioral equivalence and invariants. |
pkg/sanitize/bench_test.go |
Benchmarks sanitizer workloads. |
pkg/github/minimal_types_bench_test.go |
Benchmarks representative converters. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Balanced
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3117
Sanitization ran several allocating passes over every user-authored field regardless of content.
FilterInvisibleCharactersconverted the whole input to[]runeand back,FilterCodeFenceMetadatasplit and rejoined every line, and bluemonday ran unconditionally — so benign text paid full price on every conversion.Changes
FilterInvisibleCharactersis scan-first / copy-on-first-match. It skips runs of ASCII without decoding them (no filtered rune is ASCII, and no variation selector is either), then copies into a builder only from the first rune that actually changes. Clean input is returned as the original string with zero allocations. Contextual variation-sequence handling is unchanged, and invalid UTF-8 is still re-encoded to U+FFFD to match the[]runeround trip it replaces.FilterCodeFenceMetadatawalks lines in place instead ofstrings.Split/Join, and returns the input untouched when no line changes.sanitizeCodeFenceLinealso stops rebuilding a line whose info string is already normalized.FilterHTMLTagsskips bluemonday for provably inert input. The policy tokenizes as HTML and re-emits text throughhtml.EscapeString, so anything it can rewrite must contain one of the five charactersEscapeStringtouches (&,',",<,>— also the only way to open a tag, comment, doctype or entity), a byte the tokenizer rewrites (NUL → U+FFFD, CR folded into LF), or a byte outside ASCII that could be malformed UTF-8. The fast path accepts printable ASCII plus TAB and LF minus those five characters, which excludes all three. A loose<>&check would not be sufficient:"and'are escaped, and CR/NUL are rewritten by the tokenizer.Sanitizeskips the second invisible/code-fence pass when HTML normalization returned its input byte for byte. HTML processing is the only stage that can introduce a character its input did not contain (entity decoding), so if it is the identity there is nothing new to find, and both filters are fixed points on the first pass's output.Nothing here weakens filtering or broadens allowed HTML. Content that genuinely needs rewriting still goes through the full pipeline, and non-ASCII text still goes through bluemonday by design.
Equivalence evidence
pkg/sanitize/equivalence_test.gokeeps a verbatim copy of the previous pipeline and diffs the new code against it:isHTMLInertchecked against the live policy byte by byte (in isolation and in context) and over the whole corpus, plus 50k dense printable-ASCII strings drawn from an HTML-syntax-heavy alphabet.testing.AllocsPerRun, and a check that a list of known payloads still loses something.FuzzSanitizeMatchesReferenceImplementationandFuzzHTMLInertIsPolicyFixedPoint. Ran ~5.7M executions locally with no divergence.Benchmarks
Committed in
pkg/sanitize/bench_test.goandpkg/github/minimal_types_bench_test.go. Intel Ultra 9 185H,-count=6, benchstat:Sanitize/TitleASCIISanitize/Comment1KiBSanitize/Body64KiBSanitize/CodeFenceBodySanitize/Body64KiBUnicodeSanitize/AdversarialUnicodeSanitize/AdversarialHTMLSanitizeIssuePage(30 × 2 KiB)SanitizeCommentPage(100 × 1 KiB)ConvertToMinimalIssuePage(30 issues)ConvertToMinimalCommentPage(100 comments)All differences
p=0.002 (n=6). The residual converter allocations are the struct and slice fields, not sanitization.Projected overhead at 1,250 RPS
Per the issue's shapes, sanitizing one response:
At 1,250 RPS:
Sanitization stops being a GC-bound cost centre for ordinary content. Non-ASCII bodies still pay for bluemonday (~1.0 ms per 64 KiB, down from 2.7 ms); extending the fast path past strict ASCII is deliberately left out of this change.
Validation
script/lintclean,script/test(race) green,script/generate-docsproduces no diff.