Skip to content

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

Open
hong4rc wants to merge 2 commits into
markedjs:masterfrom
hong4rc:perf/linear-block-regexes
Open

fix: Avoid O(n^2) backtracking in HTML block close and tilde interrupt regexes#4014
hong4rc wants to merge 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! 💯

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.

2 participants