Skip to content

perf: fast-fail link tokenizing when no ')' remains ahead - #4070

Open
koding88 wants to merge 3 commits into
markedjs:masterfrom
koding88:perf/quadratic-unterminated-links
Open

perf: fast-fail link tokenizing when no ')' remains ahead#4070
koding88 wants to merge 3 commits into
markedjs:masterfrom
koding88:perf/quadratic-unterminated-links

Conversation

@koding88

@koding88 koding88 commented Aug 24, 2026

Copy link
Copy Markdown

Marked version: master (c430a64, v18.0.10)

Markdown flavor: n/a (parser performance)

Description

The inline link rule backtracks quadratically over unterminated link sequences:

marked.parse('[a](b'.repeat(20000)) // 97KB input
Input size Before After
48KB ([a](b×10k) ~550ms ~7ms
97KB ([a](b×20k) ~2500ms ~24ms
195KB ~9.9s ~28ms
390KB (minutes) ~63ms

Doubling the input quadruples the time — O(n²). At every [, the rule's greedy unquoted-href run [^ \t\n\x00-\x1f]+ swallows the entire remainder and then re-partitions it char-by-char while hunting for a closing `)" that never comes; the lexer advances one character and repeats. This is the same class as #4013 (empty-href + whitespace variant), but with non-empty content between brackets, so that fix does not cover it — and none of the existing redos guards match this shape.

The change

Lexer.inlineTokens anchors one lastIndexOf(')') scan per inline run, and Tokenizer.link consults it before running the regex: since every src it receives during the run is a suffix of that anchor, "is there a `) ahead?" is answerable in O(1). When no paren remains, the regex cannot match anyway, so skipping is semantics-preserving — the skip only removes the dead backtracking.

Details:

  • Anchors are saved/restored around nested inline runs (e.g. link-label lexing), so outer runs keep a valid anchor after recursion returns.
  • Hints live in a module-level WeakMap keyed by the tokenizer instance, so nothing appears on the public tokenizer API surface.
  • A standalone tokenizer.link() call outside a lexer run lazily anchors itself.

An earlier design that re-anchored on run-id mismatch instead of saving/restoring was 100× slower on reflink-heavy input (quadratic_inline_masking[2] blew its budget at ~10s); save/restore fixed that while keeping the unterminated-link win.

Verification

  • Differential check: 1,506 generated documents (links with titles, balanced parens, angle destinations, images, autolinks, code spans containing brackets/parens, escaped chars, reflinks + defs, nested brackets, unterminated sequences) × gfm on/off → byte-identical output before/after.
  • New guard test/specs/redos/quadratic_unterminated_links.cjs: exceeds the redos budget on master (~13.7s), passes with this change.
  • npm test green: 1791 spec tests, 191 unit tests, UMD/CJS, types, ESLint.

AI disclosure: OpenAI Codex assisted with implementation and validation; I reviewed the parser behavior, ran the differential suite, and own the contribution.

Contributor

  • Test(s) exist to ensure functionality and minimize regression.
  • If submitting new feature, it has been documented in the appropriate places.

Committer

In most cases, this should be a different person than the contributor.

The inline link rule backtracks quadratically over unterminated
link sequences. '[a](b'.repeat(n) parses in O(n^2) — 97KB took
~2.5s — because at every '[' the regex's greedy unquoted-href run
re-partitions the whole remainder while hunting for a closing ')'
that never comes.

Lexer.inlineTokens now anchors one paren scan per inline run
(lastIndexOf(')') over the paragraph), and Tokenizer.link consults
it: since every src it receives during the run is a suffix of that
anchor, 'is there a ) ahead?' is O(1). When none remains the regex
cannot match anyway, so skipping is semantics-preserving; anchors
are saved/restored around nested runs (e.g. link labels) and live
in a WeakMap so they stay off the public tokenizer API.

Measured on Node 24:

  '[a](b'.repeat(20000)   ~2500ms -> ~24ms
  '[a](b'.repeat(80000)   (OOM-adjacent) -> ~60ms

Differential check: 1506 generated documents (links with titles,
balanced parens, angle destinations, images, autolinks, code spans,
reflinks + defs, nested brackets, unterminated sequences) x gfm on/
off produce byte-identical output before and after the change.

Adds test/specs/redos/quadratic_unterminated_links.cjs following
the existing guard convention; it exceeds the redos budget on
master and passes with this change.
Copilot AI lite review requested due to automatic review settings August 24, 2026 17:34
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a quadratic-time performance issue in inline link tokenization for unterminated inline link patterns (e.g., "[a](b".repeat(n)), by adding a fast-fail check that avoids triggering pathological regex backtracking when no closing ) exists in the remaining source.

Changes:

  • Add a per-inline-run “last )” anchor hint stored/restored by Lexer.inlineTokens to enable O(1) “any ) ahead?” checks.
  • Update Tokenizer.link to consult the hint and skip link-regex evaluation when no ) can possibly occur in the remaining suffix.
  • Add a new ReDoS regression spec for quadratic unterminated inline links.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
test/specs/redos/quadratic_unterminated_links.cjs Adds a regression case to ensure unterminated inline-link inputs no longer exceed the ReDoS budget.
src/Tokenizer.ts Introduces the WeakMap-based hint and uses it in link() to fast-fail before running the expensive regex.
src/Lexer.ts Anchors/restores the hint around each inline tokenization run so Tokenizer.link can make O(1) decisions on suffix inputs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Tokenizer.ts Outdated
Comment thread src/Tokenizer.ts
@vercel

vercel Bot commented Aug 25, 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 Aug 25, 2026 2:14pm

Request Review

Per review feedback: drop the WeakMap anchor machinery in favor of a
single state flag. Lexer.inlineTokens scans for ')' once per run and
stores it in state.linkParenPossible (same pattern as linkEmitted),
restoring the previous value after nested runs. Tokenizer.link bails
out only when the flag is false, which is exactly when its regex
cannot match.

This keeps the fast-fail while avoiding a repeated full-text
includes() on reflink-heavy paragraphs, where attempting link() at
every token would otherwise re-scan the remainder each time
(quadratic_inline_masking[2]: ~1.03s vs ~144ms here, upstream ~140ms).
@koding88

Copy link
Copy Markdown
Author

Thank you for the review @UziTech — implemented along the lines you suggested, with one measurement worth sharing.

Your exact snippet (if (!src.includes(')')) return; at the top of link()) does fix the unterminated-link case (97KB: ~2500ms → ~62ms), but it re-scans the remainder on every link attempt. On reflink-heavy paragraphs that adds up: quadratic_inline_masking[2] ('[a]: x\n\n' + '[a] '.repeat(100000), no parens at all) went from ~140ms on master to ~1030ms — right over this suite's 1s redos budget — because link() is attempted once per token and each attempt re-scans.

So I kept your idea but hoisted the scan to one per inline run:

  • Lexer.inlineTokens sets state.linkParenPossible = src.includes(')') (single scan), restoring the previous value after nested runs — the same save/restore pattern linkEmitted already uses since fix: do not nest a link inside a link #4051.
  • Tokenizer.link bails only when the flag is false, which is exactly when its regex cannot match, so behavior is unchanged otherwise.

Current numbers on Node 24 (same machine):

case master this PR
'[a](b'.repeat(20000) (97KB) ~2500ms ~22ms
quadratic_inline_masking[2] ~140ms ~144ms

Differential: 1,506 generated docs × gfm on/off → byte-identical to master. Full npm test green including all redos guards.

Happy to flatten it further to the plain two-liner if you'd rather trade the masking[2] budget for fewer moving parts — say the word and I'll push that instead.

Comment thread src/Lexer.ts Outdated
// quadratic backtracking over unterminated-link inputs like
// `'[a](b'.repeat(n)`.
const prevLinkParenPossible = this.state.linkParenPossible;
this.state.linkParenPossible = src.includes(')');

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.

This also only needs to be checked if prevLinkParenPossible is true, correct? Since inlineTokens will only be called with a substring of the previous call. If the previous call didn't include a ')' than neither will this one.

When the outer inline run contains no ')' (linkParenPossible=false),
a nested run's substring can't contain one either, so skip the
redundant src.includes(')') scan. Addresses UziTech's observation
on line 354: 'This also only needs to be checked if prevLinkParenPossible
is true.'
@koding88

Copy link
Copy Markdown
Author

Good catch on both points — pushed 908ad32.

Line 354 (skip scan when outer had no )): you're right, a nested run receives a substring of the outer run, so if the outer had no ), neither does the substring. Changed to:

this.state.linkParenPossible = prevLinkParenPossible && src.includes(')');

so the includes(')') scan is skipped entirely for nested runs whose parent already established no ) is possible. The short-circuit also avoids touching the string at all for the common nested-reflink case.

Verified: 1791 spec + 191 unit tests pass, and the quadratic input ('[a](b'.repeat(50000)) still finishes in ~43ms (would be minutes without the guard).

@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 helping make marked faster! 💯

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