Skip to content

Commit 3f2c551

Browse files
authored
Avoid rescanning for a comment close after unclosed comments
`HTMLExtractor.parse_comment` searches for `-->` (or `--!>`) from each `<!--` to the end of the raw data. When there is none, it passes `<` through as data and returns `i + 1`, so the next `<!--` repeats the same failing scan. A run of n unclosed comments therefore costs O(n^2): `'<!--' * 32000` (125 KB) takes about 5 seconds to convert and 250 KB takes about 22 seconds. If no close exists after some position in `rawdata`, none exists after any later position in the same string either. The parser now records the string and position of the last failed search and skips the search when it would start at or after that position. The string is compared by identity so the record is never reused for different raw data. Output is unchanged.
1 parent 8bfb9aa commit 3f2c551

4 files changed

Lines changed: 20 additions & 1 deletion

File tree

‎.spell-dict‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ refactor
108108
refactored
109109
refactors
110110
registerExtension
111+
rescan
111112
resettable
112113
richeland
113114
RSS

‎docs/changelog.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ See the [Contributing Guide](contributing.md) for details.
2929
consisting only of `{` and some text with many trailing spaces (#1656).
3030
* Fix quadratic-time regex backtracking in hash headers when a long run of `#`
3131
characters is followed by more text on the same line.
32+
* Fix quadratic time when a document contains many unclosed HTML comments (`<!--`
33+
with no matching `-->`).
3234
* Fix regression in `BacktickInlineProcessor` where text could be lost when opening
3335
and closing backticks did not match (#1668).
3436
* Escape the language name in the `codehilite` extension's non-Pygments output to

‎markdown/htmlparser.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ def reset(self):
158158
self._cache: list[str] = []
159159
self.cleandoc: list[str] = []
160160
self.lineno_start_cache = [0]
161+
# The `rawdata` and position of the last failed search for a comment close.
162+
self._no_commentclose: tuple[str | None, int] = (None, 0)
161163

162164
super().reset()
163165

@@ -359,8 +361,15 @@ def parse_pi(self, i: int) -> int:
359361
def parse_comment(self, i, report=True):
360362
rawdata = self.rawdata
361363
assert rawdata.startswith('<!--', i), 'unexpected call to parse_comment()'
362-
match = commentclose.search(rawdata, i+4)
364+
# If no close was found after an earlier position in the same `rawdata`, there
365+
# is none after this one either. Don't rescan the rest of the document again.
366+
searched, pos = self._no_commentclose
367+
if searched is rawdata and i + 4 >= pos:
368+
match = None
369+
else:
370+
match = commentclose.search(rawdata, i+4)
363371
if not match:
372+
self._no_commentclose = (rawdata, i + 4)
364373
self.handle_data('<')
365374
return i + 1
366375
if report:

‎tests/test_syntax/blocks/test_html_blocks.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,6 +1860,13 @@ def test_multiple_unclosed_comments_no_hang(self):
18601860
'<p>&lt;!-- and &lt;!--</p>'
18611861
)
18621862

1863+
def test_many_unclosed_comments(self):
1864+
"""Test that each unclosed comment does not rescan the rest of the document."""
1865+
self.assertMarkdownRenders(
1866+
'<!--' * 50000,
1867+
'<p>' + '&lt;!--' * 50000 + '</p>'
1868+
)
1869+
18631870
def test_no_hang_issue_1586(self):
18641871
"""Test no hang condition for issue #1586."""
18651872

0 commit comments

Comments
 (0)