Skip to content

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

Closed
hong4rc wants to merge 2 commits into
masterfrom
perf/linear-block-regexes
Closed

fix: Avoid O(n^2) backtracking in HTML block close and tilde interrupt regexes#2
hong4rc wants to merge 2 commits into
masterfrom
perf/linear-block-regexes

Conversation

@hong4rc

@hong4rc hong4rc commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Follow-up to markedjs#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. markedjs#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 — markedjs#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 markedjs#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.
@hong4rc

hong4rc commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

Promoted to upstream markedjs/marked. Closing the fork-review copy; keeping the branch.

@hong4rc hong4rc closed this Jul 12, 2026
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.

1 participant