Skip to content

Bench CI: jsdom retains ~1 MB per DOMPurify string-path sanitize — run sits at a heap cliff #231

Description

@jonathanKingston

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 measured 7.6× slower than the previous green run and measured faster than (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)

  1. 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.
  2. Report process.memoryUsage() per section in bench output so retention growth is visible in the artifact instead of manifesting as timing noise.
  3. Upstream: minimal jsdom+DOMPurify repro (above) could be filed against jsdom.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions