Skip to content

fix: Avoid O(n^2) backtracking in HTML block close and tilde interrupt regexes - #4014

Merged
UziTech merged 2 commits into
markedjs:masterfrom
hong4rc:perf/linear-block-regexes
Jul 14, 2026
Merged

fix: Avoid O(n^2) backtracking in HTML block close and tilde interrupt regexes#4014
UziTech merged 2 commits into
markedjs:masterfrom
hong4rc:perf/linear-block-regexes

Conversation

@hong4rc

@hong4rc hong4rc commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #4013 — two more spots in src/rules.ts where a block-level regex backtracks quadratically on a long single line. Split into one commit each.

The first is the HTML block close. #3991 rewrote the close of the closing-tag, processing-instruction, declaration, and CDATA branches to [^\n]*\n+ so trailing text on the close line is kept. Keeping the trailing text is right, but the \n+ isn't: it requires at least one newline, so when the input ends without a trailing newline the close can never match and the engine retries every split point of the preceding lazy [\s\S]*? before finally falling through to $. That's O(n²):

marked.parse('<!x ' + 'a>'.repeat(80000));            // ~5s
marked.parse('<script>' + 'a</script>'.repeat(40000)); // ~6.5s

Switching \n+ back to \n* closes on the first match. Whenever a trailing newline is present (the normal case) \n* and \n+ consume exactly the same text, so output is unchanged — #3991's own trailing-text fixtures still pass.

The second is the paragraph/table/blockquote interrupt check. Its fence sub-pattern is (?:`{3,}(?=[^`\n]*\n)|~{3,})[^\n]*\n. The backtick branch is guarded by a lookahead, but ~{3,} isn't, and it overlaps with the following [^\n]*, so a long run of tildes with no newline backtracks quadratically:

marked.parse('intro\n' + '~'.repeat(80000));           // ~5s

Because ~{3,} is always immediately followed by [^\n]*, ~~~[^\n]* matches exactly the same strings, so replacing ~{3,} with ~~~ removes the overlap without changing what matches. The real fenced-code tokenizer (which captures the fence length via a group and a $ alternative) is left untouched — only the three interrupt copies change.

Both changes are behavior-preserving: the full spec and unit suites pass with identical output, and I added a quadratic_*.cjs guard next to #4013's for each.

hong4rc added 2 commits July 12, 2026 11:36
The close branches end in `[^\n]*\n+`; the trailing `\n+` requires a
newline, so at EOF the close can't match and the engine retries every
split of the lazy `[\s\S]*?` before falling through to `$`, which is
O(n^2). `\n*` closes on first match and consumes identical text whenever
a trailing newline is present (the `[^\n]*` was added in markedjs#3991).
The backtick branch is guarded by a lookahead but `~{3,}` isn't, and it
overlaps the following `[^\n]*`, so a long newline-less tilde run
backtracks quadratically. Since `~{3,}` is always followed by `[^\n]*`,
`~~~` matches the same strings without the overlap. The real fences
tokenizer is left untouched.
@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

@hong4rc is attempting to deploy a commit to the MarkedJS Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marked-website Ready Ready Preview, Comment Jul 12, 2026 5:16am

Request Review

@UziTech UziTech left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing theses! 💯

@UziTech
UziTech merged commit f945fc5 into markedjs:master Jul 14, 2026
8 checks passed
github-actions Bot pushed a commit that referenced this pull request Jul 21, 2026
## [18.0.7](v18.0.6...v18.0.7) (2026-07-21)

### Bug Fixes

* Avoid O(n^2) backtracking in HTML block close and tilde interrupt regexes ([#4014](#4014)) ([f945fc5](f945fc5)), closes [#3991](#3991)
* Avoid O(n^2) masked source rebuild in inline tokenizer ([#4017](#4017)) ([9154f8f](9154f8f))
* keep empty list after blockquote as a sibling block ([#4004](#4004)) ([3f144a0](3f144a0))
* preserve code spans adjacent to tildes ([#4012](#4012)) ([0de7188](0de7188))
* Recognize setext headings whose first line starts with # ([#4015](#4015)) ([f056437](f056437)), closes [#1](#1)
* treat a line of only tabs as a blank line between paragraphs ([#4007](#4007)) ([bc2f121](bc2f121))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants