Fix quadratic blowup in Bun.markdown on unterminated inline HTML openers (#31271)
### Problem
`Bun.markdown` is quadratic on paragraphs containing many unterminated
inline HTML openers (found by parser fuzzing — a ~260 KB input of
bracket runs plus repeated `foo <!-- this is a --` lines spun in
`find_html_tag`):
```js
Bun.markdown.html("x <!-- y --\n".repeat(100000)); // minutes before this change
```
Measured on a debug+ASAN build (input doubled twice, 18 KB → 36 KB → 72
KB unless noted):
| input | before | after |
|---|---|---|
| unterminated `<!--` flood | 133 / 521 / 2052 ms (×3.9 per doubling) |
7 / 11 / 22 ms (×2.0) |
| unterminated `<?` flood | 73 / 281 / 1098 ms | 5 / 10 / 19 ms |
| unterminated `<!DECL` flood | 75 / 287 / 1124 ms | 5 / 10 / 20 ms |
| unterminated `< <!-- x` repeated (links interleaved) | 88 / 318 / 1205 ms | 16
/ 32 / 66 ms |
| `[<!-- x](u) <!-- y` repeated (opener inside label) | 215 / 819 / 3201
ms | 18 / 37 / 75 ms |
| `[<!-- a](u)[<!-- b](v) <!-- c` repeated (adjacent labels) | quadratic
| 33 / 66 / 136 ms |
| `[<!-- [<!-- … x](u)](u)…` (nested labels, 5–40 KB) | 18 / 55 / 202 /
760 ms | 6 / 10 / 20 / 42 ms |
| one link with a `<!--`-flooded label | quadratic | 4 / 7 / 14 ms |
| the original 260 KB fuzzer input | 1093 ms | 94 ms |
Terminated comments, unclosed quoted attributes, autolinks, and the
bracket-flood family fixed in #31241 were already linear and are
unchanged.
### Cause
`find_html_tag` (src/md/inlines.rs) resolves a `<!--` / `<?` / `<!`
declaration / `<![CDATA[` candidate by scanning forward for its closing
delimiter (`-->`, `?>`, `>`, `]]>`). When the delimiter never appears,
the scan walks to the end of the inline slice and returns `None` — and
the next such opener repeats the same full-length scan. The scans are
repeated per pass (bracket-map builder, emphasis collection, rendering)
and per link-label walk, so n openers in an n-byte paragraph cost O(n²).
### Fix
Add a small memo on the `Parser` (`HtmlScanMemo`): when a search for one
of the four fixed terminators reaches the end of the slice without a
match, record the scan's start position for that terminator kind. Any
later scan of the same kind starting at or beyond that position must
also fail, so it returns immediately.
This cannot change behavior: if a later candidate's terminator existed,
the earlier failed scan would have found it (the search ranges are
nested). Two details make the memo cover every scan path:
- The memo is keyed by slice identity (address + length), and a recorded
fact also answers queries for any **sub-slice** of that slice by
translating positions with the sub-slice's offset — `find_html_tag` runs
on link-label sub-slices during `label_contains_link` and nested-link
checks, and "no terminator after position P of the paragraph" covers
every later position of every label inside it. Sub-slice scans never
overwrite the enclosing entry.
- `process_inline_content` starts each slice from an empty memo and
restores the caller's memo on return, so recursive label rendering keeps
what the paragraph already learned, while recycled merged-line buffers
and transient table-cell buffers can never alias a previous slice's
entry.
### Tests
`test/js/bun/md/md-edge-cases.test.ts`, new "pathological inline HTML
inputs" block:
- a child-process test renders 1.2 MB floods of each unterminated opener
kind, the fuzzer-shaped bracket+comment pattern,
link/image/reference-link interleavings, adjacent and nested
comment-label links, and comment-flooded link labels with a 30 s kill
switch — it times out without this change (30.0 s, killed) and completes
in a few seconds with it (debug+ASAN)
- conformance tests lock in that unterminated openers still render as
escaped text, a terminated construct after a failed opener of another
kind is still recognized, multi-line comments still become raw HTML,
consecutive same-length paragraphs don't share scan state, and
links/images/nested link candidates interleaved with unterminated
openers render byte-identically
Full markdown suite (CommonMark spec, GFM, edge cases — 1038 tests) and
the markdown CLI entrypoint tests pass. R
robobun committed
aff1bb1a060fe257a5777bb08afd2e7da193d2f7
Parent: 14b398c
Committed by GitHub <noreply@github.com>
on 5/24/2026, 2:01:15 AM