fix(markdown): avoid quadratic parse time on long unbroken words - #2536
Conversation
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.
There was a problem hiding this comment.
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.
| } 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|$)')); | ||
| } |
There was a problem hiding this comment.
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
- When building foundational components, prioritize robust, future-proof implementations over simpler ones that may introduce subtle bugs or rely on unintended behavior.
| 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)); | ||
| }); |
There was a problem hiding this comment.
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));
}
});|
FYI @lrhn I know you like this kind of thing |
srawlins
left a comment
There was a problem hiding this comment.
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', () { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
|
Thanks much! |
…, 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>
…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
The inline parser adds a plain-text "accelerator" regex that fast-forwards runs of word characters. Both variants require a trailing whitespace:
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.markdownToHtmlon a single long word, no spaces: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: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.dartparses a 200k-character word and asserts it finishes well under the old timings.dart analyze --fatal-infosanddart format --set-exit-if-changedare clean.