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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants