Skip to content

fix(markdown): avoid quadratic parse time on long unbroken words - #2536

Merged
srawlins merged 1 commit into
dart-lang:mainfrom
herdiyana256:fix-markdown-quadratic-plaintext
Sep 2, 2026
Merged

srawlins merged 1 commit into
dart-lang:mainfrom
herdiyana256:fix-markdown-quadratic-plaintext

Conversation

@herdiyana256

Copy link
Copy Markdown
Contributor

The inline parser adds a plain-text "accelerator" regex that fast-forwards runs of word characters. Both variants require a trailing whitespace:

if (document.hasCustomInlineSyntaxes) {
  syntaxes.add(TextSyntax(r'[A-Za-z0-9]+(?=\s)'));
} else {
  syntaxes.add(TextSyntax(r'[ \tA-Za-z0-9]*[A-Za-z0-9](?=\s)'));
}

When a run of word characters reaches the end of a line without a trailing whitespace, for example a single long word, the greedy */+ consumes the whole run, the (?=\s) lookahead fails, and the engine backtracks one character at a time. The parser then advances one position and repeats the full scan, so parsing is quadratic in the length of the run.

markdownToHtml on a single long word, no spaces:

32,000 chars  : 12,926 ms
16,000 chars  :  3,131 ms
 8,000 chars  :    823 ms

The same character count split into short words is under 1 ms, confirming it is the unbroken run, not the length, that is slow. This is easy to reach with untrusted input (a long URL, a base64 blob, or any unbroken token in user-supplied markdown), so it is a denial-of-service risk for anything rendering markdown it did not author.

The fix lets the run also end at $, so it matches in a single step when it reaches the end of a line:

syntaxes.add(TextSyntax(r'[A-Za-z0-9]+(?=\s|$)'));
...
syntaxes.add(TextSyntax(r'[ \tA-Za-z0-9]*[A-Za-z0-9](?=\s|$)'));

The match content is unchanged. A trailing word with no following whitespace was already emitted as plain text, just one character at a time. After the fix a 2,000,000-character word parses in 35 ms.

The full suite (2763 tests, including the CommonMark and GFM conformance suites) passes unchanged, and a regression test in test/regression_test.dart parses a 200k-character word and asserts it finishes well under the old timings. dart analyze --fatal-infos and dart format --set-exit-if-changed are clean.

The plain-text accelerator regex added by the inline parser required a
trailing whitespace: `[ \tA-Za-z0-9]*[A-Za-z0-9](?=\s)` (and the custom
syntax variant `[A-Za-z0-9]+(?=\s)`). When a run of word characters
reaches the end of a line without a trailing whitespace, for example a
single very long word, the greedy `*`/`+` consumes the whole run, the
`(?=\s)` lookahead fails, and the engine backtracks one character at a
time. The parser then advances one position and repeats the full scan,
so parsing is quadratic in the length of the run.

markdownToHtml on a single 32k-character word took about 13 seconds
before this change; 100k did not finish in a reasonable time. Such a run
is easy to reach with untrusted input (a long URL, a base64 blob, or any
unbroken token in user-provided markdown), so this is a denial-of-service
risk for anything that renders markdown it did not author.

Allowing the run to also end at `$` (`(?=\s|$)`) lets it match in a
single step when it reaches the end of a line, making parsing linear. The
match content is unchanged: a trailing word with no following whitespace
was already emitted as plain text, just character by character. The full
test suite (2763 tests, including the CommonMark and GFM conformance
suites) passes unchanged, and a regression test parses a 200k-character
word well under the previous timings.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request addresses a performance issue where parsing a long unbroken run of word characters resulted in quadratic execution time. The fix updates the plain-text accelerator regex to allow matching at the end of a line and adds a regression test. The reviewer notes that the regex is still vulnerable to quadratic backtracking if the long word is followed by punctuation (e.g., an exclamation mark) and suggests a more robust, future-proof parsing approach along with an expanded regression test to cover these edge cases.

Comment on lines 80 to 82
} else {
syntaxes.add(TextSyntax(r'[ \tA-Za-z0-9]*[A-Za-z0-9](?=\s)'));
syntaxes.add(TextSyntax(r'[ \tA-Za-z0-9]*[A-Za-z0-9](?=\s|$)'));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

While adding |$ to the lookahead fixes the quadratic backtracking when a long word is at the end of a line or string, the regex still suffers from quadratic backtracking if a long word is followed by punctuation (e.g., a * 200000 + !). Since the lookahead (?=\s|$) fails on !, the engine will backtrack and retry at every single character position, leading to the same quadratic performance issue.

Instead of relying on the assumption that there are no default inline syntaxes starting with a word character (which is not future-proof and could introduce subtle bugs if new syntaxes are added), we should prioritize a robust, future-proof implementation. We can dynamically construct a negated character class based on the active syntaxes' trigger characters, or use a character-by-character scanner to safely consume plain text without backtracking.

References
  1. When building foundational components, prioritize robust, future-proof implementations over simpler ones that may introduce subtle bugs or rely on unintended behavior.

Comment on lines +70 to +82
test('long unbroken word does not take quadratic time', () {
// The plain-text accelerator regex required a trailing whitespace, so a
// long run of word characters that reached the end of a line without one
// (a single long word) forced the regex to backtrack across the whole run
// at every position, giving quadratic parse time. A ~200k character word
// used to take tens of seconds; it should now be effectively instant.
final input = '${'a' * 200000}\n';

final time = Stopwatch()..start();
final html = markdownToHtml(input); // Should not hang.
expect(html, isNotNull); // To use the output.
expect(time.elapsedMilliseconds, lessThan(10000));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Let's expand the regression test to cover both the end-of-line case and the followed-by-punctuation case. This ensures that the parser remains immune to quadratic backtracking even when a long word is followed by punctuation.

  test('long unbroken word does not take quadratic time', () {
    // The plain-text accelerator regex required a trailing whitespace, so a
    // long run of word characters that reached the end of a line without one
    // (a single long word) forced the regex to backtrack across the whole run
    // at every position, giving quadratic parse time. A ~200k character word
    // used to take tens of seconds; it should now be effectively instant.
    for (final suffix in [r'\n', '!']) {
      final input = '\n' + 'a' * 200000 + suffix;

      final time = Stopwatch()..start();
      final html = markdownToHtml(input); // Should not hang.
      expect(html, isNotNull); // To use the output.
      expect(time.elapsedMilliseconds, lessThan(10000));
    }
  });

@srawlins

Copy link
Copy Markdown
Member

FYI @lrhn I know you like this kind of thing

@srawlins srawlins 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.

Alright let's land it. If the benchmark test is flaky, we can mark it local (or skip or something).

expect(html, '<h2>I am paragraph</h2>\n');
});

test('long unbroken word does not take quadratic time', () {

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.

CC @natebosch do you know if we have any precedent or guidelines or systems for benchmarks? I'm very very hesitant to check in a test like this which I imagine will likely become flaky on certain CI situations. I'd rather have no benchmark than a flaky benchmark. Can we take advantage of any GitHub CI or LUCI infrastructure?

@herdiyana256 herdiyana256 Aug 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Happy to make this a plain correctness regression test with no timing assertion, so there's nothing to go flaky on slower CI it would just parse the pathological inputs and assert the output is correct, and rely on a suite-level timeout if the quadratic behavior ever regresses. Or I can mark it local/skip if you'd prefer. Whichever you like and I'll update it.

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.

We can leave it for now.

@srawlins
srawlins merged commit cacb5a5 into dart-lang:main Sep 2, 2026
24 checks passed
@srawlins

srawlins commented Sep 2, 2026

Copy link
Copy Markdown
Member

Thanks much!

copybara-service Bot pushed a commit to dart-lang/sdk that referenced this pull request Sep 11, 2026
…, web, webdriver

Revisions updated by `dart tools/rev_sdk_deps.dart`.

core (https://github.com/dart-lang/core/compare/773de9d..7e8caaf):
  7e8caafa  Thu Sep 10 12:51:06 2026 +0200  Jonas Finnemann Jensen  Fix doc comment in FixedDateTimeFormatter (dart-lang/core#998)
  0fbe0cc3  Thu Sep 3 16:26:19 2026 -0700  Nate Bosch  Mark Context and Style as final (dart-lang/core#995)
  1f563bb2  Tue Sep 1 21:54:29 2026 +0000  dependabot[bot]  Bump the github-actions group with 5 updates (dart-lang/core#996)
  b80e4885  Fri Aug 28 12:18:12 2026 +0200  Lasse R.H. Nielsen  Release package:platform 3.2 (dart-lang/core#994)
  9727aadc  Wed Aug 26 09:34:02 2026 -0700  Nate Bosch  Add new lints (dart-lang/core#989)

dartdoc (https://github.com/dart-lang/dartdoc/compare/2737669..df2f2cd):
  df2f2cde  Tue Sep 8 11:09:41 2026 +0200  dependabot[bot]  Bump analyzer_testing from 0.3.4 to 0.4.1 (dart-lang/dartdoc#4282)
  b028f551  Tue Sep 1 18:56:04 2026 +0000  dependabot[bot]  Bump the github-actions group with 2 updates (dart-lang/dartdoc#4281)

ecosystem (https://github.com/dart-lang/ecosystem/compare/cda8bd5..1bdafd8):
  1bdafd8  Wed Sep 2 16:31:27 2026 -0700  Nate Bosch  Tighten workflow for posting PR comments (dart-lang/ecosystem#451)
  fc3dd9a  Mon Aug 31 11:38:33 2026 -0700  Kevin Moore  fix(firehose): update existing publishing PR comments when no packages are ready to publish (dart-lang/ecosystem#449)
  e0a78b4  Mon Aug 31 11:12:56 2026 -0700  Kevin Moore  fix(firehose): bump dependency_validator pinned hash to 5.0.6 (dart-lang/ecosystem#450)
  60fca9f  Tue Aug 25 16:31:57 2026 -0700  Kevin Moore  Condense firehose package publishing table in PR validation (dart-lang/ecosystem#445)
  b79f68c  Mon Aug 24 05:47:23 2026 -0700  Kevin Moore  chore(health): remove 3rd-party coveralls action dependency (dart-lang/ecosystem#446)

http (https://github.com/dart-lang/http/compare/a9176ac..066e158):
  066e158  Wed Sep 9 17:15:24 2026 +0200  Eve Chen  Skip chunked framing when contentLength is null (dart-lang/http#1970)
  a6145c3  Wed Sep 9 09:26:05 2026 +1000  Liam Appelbe  [cupertino_http] Migrate to FFIgen 22 (dart-lang/http#1986)
  92b20d1  Fri Sep 4 10:29:18 2026 -0700  Brian Quinlan  test(conformance): HEAD with content-length set (dart-lang/http#1979)
  bb553a6  Fri Sep 4 11:40:41 2026 +1000  Liam Appelbe  [cronet_http] Migrate to JNIgen v1 and use the Dart config API (dart-lang/http#1980)
  deacd15  Thu Sep 3 16:09:07 2026 -0700  Brian Quinlan  chore(cupertino_http): ready to release 3.1.0 (dart-lang/http#1985)
  ace65bc  Wed Sep 2 17:01:14 2026 -0700  Brian Quinlan  ci(cupertino_http): only run iOS tests when run-ios-tests label is present (dart-lang/http#1984)
  7a79b45  Wed Sep 2 17:00:49 2026 -0700  Brian Quinlan  ci(cupertino_http): enable caching for Flutter SDK and pub dependencies (dart-lang/http#1982)
  8dbb44c  Wed Sep 2 17:00:19 2026 -0700  Brian Quinlan  chore(cupertino_http): migrate ios_test from CocoaPods to Swift Package Manager (dart-lang/http#1983)
  b815a06  Wed Sep 2 23:55:13 2026 +0200  stwime  [cupertino_http] Regenerate bindings with ffigen 21 (dart-lang/http#1975)
  0d5bee4  Wed Sep 2 10:50:41 2026 -0700  dependabot[bot]  chore(deps): bump the github-actions group across 1 directory with 10 updates (dart-lang/http#1976)
  6543145  Mon Aug 24 16:32:07 2026 +0100  Ademola Fadumo  feat(http2): add a pooled, multiplexed HTTP/2 http.Client (dart-lang/http#1956)
  a0d49cb  Wed Aug 19 18:22:11 2026 -0700  Brian Quinlan  fix(http2): reject server push when disabled (dart-lang/http#1966)
  8007c92  Wed Aug 19 17:57:46 2026 -0700  Brian Quinlan  fix(http2): reject requests that exceed `SETTINGS_MAX_CONCURRENT_STREAMS` (dart-lang/http#1965)
  1827980  Wed Aug 19 17:06:20 2026 -0700  dart-ecosystem-bot[bot]  Tidy: package:cupertino_http (dart-lang/http#1968)
  a24ab46  Wed Aug 19 17:03:03 2026 -0700  Brian Quinlan  chore: upgrade to jnigen 0.17.0 (dart-lang/http#1964)
  9fa7266  Tue Aug 18 16:35:25 2026 -0700  Kevin Moore  chore: harden GitHub Actions workflows against Zizmor findings (dart-lang/http#1971)

i18n (https://github.com/dart-lang/i18n/compare/2fd9412..1f5ea2f):
  1f5ea2fb  Tue Sep 8 13:12:25 2026 +0200  Moritz  [intl4x] Add intl4x.brittle_i18n_testing environment define (dart-lang/i18n#1093)

native (https://github.com/dart-lang/native/compare/38fe179..84d7eda):
  84d7edac0  Wed Sep 9 07:38:19 2026 +0200  Jakob Kordež  [ffigen] Resolve unexposed C++ types through their canonical type (dart-lang/native#3608)
  7b77c03a9  Wed Sep 9 10:31:06 2026 +0530  Sivapriya  [jnigen] Add javadoc.io fallback for missing Javadoc (dart-lang/native#3586)
  520d280d1  Wed Sep 9 06:57:16 2026 +0200  Jakob Kordež  [ffigen] Treat class declarations like structs, decide C++ class binding by POD-ness (dart-lang/native#3607)
  350ed4541  Tue Sep 8 09:55:21 2026 +1000  Liam Appelbe  [ffigen] Prepare to publish (dart-lang/native#3620)
  2cb086a2f  Mon Sep 7 12:15:34 2026 +1000  Liam Appelbe  [ffigen] Migrate remaining FFIgen YAML configs (dart-lang/native#3614)
  dc2937e7c  Mon Sep 7 11:07:46 2026 +1000  Liam Appelbe  [ffigen] Symbol file imports (dart-lang/native#3617)
  c69575ce5  Fri Sep 4 04:05:59 2026 +0200  Jakob Kordež  [ffigen] Skip C++ methods with unbindable return types (dart-lang/native#3591)
  5d6f82d9d  Fri Sep 4 08:41:35 2026 +1000  Liam Appelbe  [objective_c] Migrate ffigen yaml configs (dart-lang/native#3615)
  ef6db8652  Fri Sep 4 08:41:20 2026 +1000  Liam Appelbe  [ffigen] Migrate test YAML configs (dart-lang/native#3613)
  9f3dae7e6  Fri Sep 4 08:38:55 2026 +1000  Liam Appelbe  [ffigen] Migrate FFIgen configs across the repo (dart-lang/native#3616)
  895977bca  Fri Sep 4 08:37:25 2026 +1000  Liam Appelbe  [jnigen] Prepare to publish v1.0.0 (dart-lang/native#3612)
  03f1d8e29  Thu Sep 3 09:13:56 2026 +1000  Liam Appelbe  [ffigen] Config cleanup (dart-lang/native#3606)
  9560e6c8a  Wed Sep 2 09:58:51 2026 +1000  Liam Appelbe  [jnigen] Migrate YAMLs to Dart (dart-lang/native#3600)
  8836b56ed  Tue Sep 1 05:35:52 2026 +0000  dependabot[bot]  [infra] Bump the github-actions group with 7 updates (dart-lang/native#3604)
  3041c6e88  Tue Sep 1 09:06:17 2026 +1000  Liam Appelbe  [jnigen] Little bits of cleanup (dart-lang/native#3599)
  c4af7b839  Tue Sep 1 09:01:03 2026 +1000  Liam Appelbe  [jni_flutter] Bump kotlin version (dart-lang/native#3598)
  ff508aeb2  Thu Aug 27 12:30:15 2026 +0200  Daco Harkes  [code_assets] [native_toolchain_c] Add arm64e (dart-lang/native#3589)
  f914da89c  Thu Aug 27 05:37:06 2026 +0200  Jakob Kordež  [ffigen] Fix infinite recursion when parsing cyclic C++ class references (dart-lang/native#3584)
  0f05f8895  Thu Aug 27 11:36:48 2026 +1000  Liam Appelbe  [ffigen][jnigen] Clean up dartdoc categories (dart-lang/native#3587)
  ae80f0dfa  Wed Aug 26 18:23:47 2026 +0200  Alexander Thomas  Merge pull request `#3588` from dart-lang/merge-cherry-pick-3581
  bcf1529b9  Wed Aug 26 07:51:49 2026 -0700  Brian Quinlan  Merge branch 'main' into merge-cherry-pick-3581
  bd5e0ff05  Wed Aug 26 14:24:50 2026 +1000  Liam Appelbe  [ffigen] Final config cleanup (dart-lang/native#3575)
  363e9ae91  Tue Aug 25 14:35:54 2026 -0700  Brian Quinlan  Merge remote-tracking branch 'origin/cherry-pick-3581' into merge-cherry-pick-3581
  d51c53334  Tue Aug 25 01:07:51 2026 -0700  Brian Quinlan  [hooks_runner] use target path separator (dart-lang/native#3581)
  7ee7629d6  Tue Aug 25 13:59:24 2026 +0530  Varad Raj Agrawal  [native_toolchain_c] Inject FileSystem for file system access (dart-lang/native#3555)

shelf (https://github.com/dart-lang/shelf/compare/fb3f931..f36dd68):
  f36dd68  Tue Sep 1 12:29:24 2026 +0000  dependabot[bot]  build(deps): bump dart-lang/setup-dart in the github-actions group (dart-lang/shelf#539)
  3dbf65b  Thu Aug 27 16:05:14 2026 -0700  Kevin Moore  fix(shelf_static): Handle int overflow during byte range parsing (dart-lang/shelf#538)

tools (https://github.com/dart-lang/tools/compare/441ff29..682f285):
  682f2854  Thu Sep 10 09:08:32 2026 -0700  Keerti Parthasarathy  [unified_analytics] Update code to mock `isExternal` in tests. (dart-lang/tools#2594)
  b09f9a33  Fri Sep 4 11:46:08 2026 -0700  Kevin Moore  [api_summary] Support dart:js_interop annotations (@js and @JSExport) (dart-lang/tools#2527)
  292130b6  Thu Sep 3 19:27:26 2026 -0700  Kevin Moore  [api_summary] Introduce ApiFacet model and migrate package:meta annotations (dart-lang/tools#2526)
  cacb5a56  Wed Sep 2 10:03:40 2026 +0700  herdiyanitdev  fix(markdown): avoid quadratic parse time on long unbroken words (dart-lang/tools#2536)
  10ea88d0  Tue Sep 1 10:08:46 2026 -0700  Nate Bosch  Remove @internal annotations for now (dart-lang/tools#2576)
  3306952a  Tue Sep 1 06:11:16 2026 +0000  dependabot[bot]  build(deps): bump the github-actions group with 4 updates (dart-lang/tools#2577)
  94ee86ed  Fri Aug 28 14:54:46 2026 -0700  Kevin Moore  feat: export ErrorListener and ErrorCollector from yaml.dart (dart-lang/tools#2551)
  c7f7a63f  Fri Aug 28 12:25:32 2026 -0700  Nate Bosch  Prepare to publish packages (dart-lang/tools#2562)
  3b3b22c2  Fri Aug 28 09:10:36 2026 +0200  Jonas Finnemann Jensen  [extension_discovery] Fixup review comments (dart-lang/tools#2557)

web (https://github.com/dart-lang/web/compare/6b84f81..e5c6c02):
  e5c6c02  Tue Sep 1 06:54:56 2026 +0000  dependabot[bot]  Bump the github-actions group with 2 updates (dart-lang/web#574)
  c4d8884  Thu Aug 20 21:31:53 2026 +0000  dependabot[bot]  Bump the github-actions group with 3 updates (dart-lang/web#569)
  6d96d9e  Thu Aug 20 14:08:47 2026 -0700  Kevin Moore  chore: harden GitHub Actions workflows against Zizmor findings (dart-lang/web#568)

webdriver (https://github.com/google/webdriver.dart/compare/3a711eb..9b2e87d):
  9b2e87d  Wed Sep 9 12:55:07 2026 -0700  Kevin Moore  ci: align publish workflow and enable direct PR comments (google/webdriver.dart#348)
  1da600f  Fri Sep 4 11:40:38 2026 -0700  dependabot[bot]  Bump the actions group across 1 directory with 3 updates (google/webdriver.dart#347)
  0046657  Fri Sep 4 13:10:07 2026 -0400  Nate Biggs  Publish version 3.2.0 (google/webdriver.dart#346)


R=bquinlan@google.com

Change-Id: Ib3be7bf3d3a0c91ebeb01002b7e63fcb86de1f92
TAG=agy
CONV=e6fd1c32-ded3-443f-83e5-2e15b4ffaccb
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/544260
Commit-Queue: Johnni Winther <johnniwinther@google.com>
Reviewed-by: Samuel Rawlins <srawlins@google.com>
Reviewed-by: Johnni Winther <johnniwinther@google.com>
Auto-Submit: Nate Bosch <nbosch@google.com>
tomyeh added a commit to tomyeh/markd that referenced this pull request Sep 24, 2026
…ake AutolinkExtensionSyntax.emailPattern public

* Fix quadratic parsing time on a long unbroken run of word characters ending a line (dart-lang/tools#2536)
* Fix performance and correctness of the HTML comment parser; processing instructions, declarations and CDATA sections may span lines; tag names accept uppercase letters (dart-lang/tools#2121)
* Fix nested list structure when indented by tabs (dart-lang/tools#2173)
* Escape image description text when assigning it to the alt attribute (dart-lang/tools#2478)
* markdownToHtml respects enableTagfilter when inlineOnly is true; the tag filter covers tags with attributes, spaces, self-closing and closing tags (dart-lang/tools#2471, dart-lang/tools#2472)
* Bound the email regex quantifiers in AutolinkExtensionSyntax (dart-lang/tools#2474)
* ExtensionSet.gitHubFlavored: setext heading underline takes precedence over an empty list item (dart-lang/tools#2510)
* AutolinkExtensionSyntax.emailPattern is public, for reuse by Quire

Co-author: Claude Fable 5.1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants