Type: CI infrastructure / upstream · Area: scripts/bench-streaming.mts, .github/workflows/ci.yml bench job, jsdom + DOMPurify string path
Symptom
The bench job OOM'd (exit 134, Ineffective mark-compacts near heap limit, 6144 MB) on PR #229's footnote-wrap commit, during the real-document 4× case — after 1× measured 7.6× slower than the previous green run and 2× measured faster than 1× (GC-storm noise: average mu = 0.10, 4.5 s mark-compacts).
Root cause (measured)
jsdom permanently retains memory on every string-path DOMPurify.sanitize call, proportional to input size, surviving forced GC. Bare reproduction with no library code involved:
input 4469 bytes (bench-prefix-sized), DOMPurify on a fresh JSDOM window:
baseline 44 MB
after 1000 sanitize calls 867 MB (~0.84 MB/call retained, post-GC)
after 2000 sanitize calls 1688 MB (linear)
Isolating the pipeline stages over the footnotes-heavy fixture (chunk=8, one pass, forced GC after):
renderMarkdownUnsafe only +1 MB (parser/renderer clean)
+ sanitizeRenderedMarkdown +591 MB ← the retainer
string emitter pass +580 MB (same path)
DOM emitter pass +13 MB (sanitizeInto fragment path — barely retains)
The bench makes thousands of string-path sanitize calls with growing prefixes, so every run accumulates multiple GB regardless of the code under test. Green runs on main were completing within ~1 GB of the 6144 MB cap; PR #229's unresolved-ref wrap added ~25% more sanitize-visible nodes on footnote-heavy fixtures (80 extra spans/update through DOMPurify) and tipped the same slope over the ceiling. The wrap itself has no leak (branch and main retain identically per pass on the corpus) and no algorithmic regression (all growth guards ~2×/doubling, limit 3×).
This is jsdom-specific: real browsers and the native Sanitizer path are unaffected, and jsdom's own sanitizeInto fragment path barely retains. Likely the per-call DOMParser.parseFromString document DOMPurify creates being retained by jsdom internals.
Mitigation shipped (PR #229)
ci.yml bench heap raised 6144 → 12288 MB with the measurement documented inline — safe because the workload is fixed-size, and it restores the headroom the job silently lost as fixtures grew. (The old comment already said the cap was raised once before for the same symptom — this cliff has been hit twice now.)
Durable fixes to consider (post-1.0)
- Per-section subprocesses: run each bench section (fixtures / scaling / scan / code-block / real-doc) in a child process so retention can't accumulate across sections, and a section OOM identifies itself.
- Report
process.memoryUsage() per section in bench output so retention growth is visible in the artifact instead of manifesting as timing noise.
- Upstream: minimal jsdom+DOMPurify repro (above) could be filed against jsdom.
- Docs: note in LAZY-LOADING/SECURITY that long-running jsdom SSR loops should prefer the fragment path (
sanitizeInto via the DOM emitter) or recycle workers; browser deployments are unaffected.
Type: CI infrastructure / upstream · Area:
scripts/bench-streaming.mts,.github/workflows/ci.ymlbench job, jsdom + DOMPurify string pathSymptom
The bench job OOM'd (exit 134,
Ineffective mark-compacts near heap limit, 6144 MB) on PR #229's footnote-wrap commit, during the real-document 4× case — after1×measured 7.6× slower than the previous green run and2×measured faster than1×(GC-storm noise:average mu = 0.10, 4.5 s mark-compacts).Root cause (measured)
jsdom permanently retains memory on every string-path
DOMPurify.sanitizecall, proportional to input size, surviving forced GC. Bare reproduction with no library code involved:Isolating the pipeline stages over the
footnotes-heavyfixture (chunk=8, one pass, forced GC after):The bench makes thousands of string-path sanitize calls with growing prefixes, so every run accumulates multiple GB regardless of the code under test. Green runs on
mainwere completing within ~1 GB of the 6144 MB cap; PR #229's unresolved-ref wrap added ~25% more sanitize-visible nodes on footnote-heavy fixtures (80 extra spans/update through DOMPurify) and tipped the same slope over the ceiling. The wrap itself has no leak (branch and main retain identically per pass on the corpus) and no algorithmic regression (all growth guards ~2×/doubling, limit 3×).This is jsdom-specific: real browsers and the native Sanitizer path are unaffected, and jsdom's own
sanitizeIntofragment path barely retains. Likely the per-callDOMParser.parseFromStringdocument DOMPurify creates being retained by jsdom internals.Mitigation shipped (PR #229)
ci.ymlbench heap raised 6144 → 12288 MB with the measurement documented inline — safe because the workload is fixed-size, and it restores the headroom the job silently lost as fixtures grew. (The old comment already said the cap was raised once before for the same symptom — this cliff has been hit twice now.)Durable fixes to consider (post-1.0)
process.memoryUsage()per section in bench output so retention growth is visible in the artifact instead of manifesting as timing noise.sanitizeIntovia the DOM emitter) or recycle workers; browser deployments are unaffected.