From 45dd4ec3c10a724f244dd6e6545dcf03db45fb49 Mon Sep 17 00:00:00 2001 From: HongTin2104 Date: Thu, 5 Nov 2026 20:25:34 +0700 Subject: [PATCH 1/2] fix: reset _escape_table and _code_table on each convert() call Reusing a Markdown() instance was retaining entries in _escape_table and _code_table across documents. These tables grew with each call, causing quadratic slowdown and incorrect output: a backslash before a URL seen in an earlier document was silently dropped because the URL hash was already in _escape_table. Fix: extract the table initialisation into _reset_tables() and call it from both __init__() and reset(). The call in reset() is placed before _setup_extras() because the middle-word-em extra reads _escape_table. Closes #763 --- lib/markdown2.py | 4 +++ test/test_reuse_instance.py | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 test/test_reuse_instance.py diff --git a/lib/markdown2.py b/lib/markdown2.py index 9be0a262..eff02f6f 100755 --- a/lib/markdown2.py +++ b/lib/markdown2.py @@ -382,6 +382,9 @@ def __init__( self._outdent_re = re.compile(r'^(\t|[ ]{1,%d})' % tab_width, re.M) self.cli = cli + self._reset_tables() + + def _reset_tables(self): self._escape_table = g_escape_table.copy() self._code_table = {} if "smarty-pants" in self.extras: @@ -395,6 +398,7 @@ def reset(self): self.html_spans = {} self.list_level = 0 self.extras = self._instance_extras.copy() + self._reset_tables() self._setup_extras() self._toc = [] diff --git a/test/test_reuse_instance.py b/test/test_reuse_instance.py new file mode 100644 index 00000000..19248029 --- /dev/null +++ b/test/test_reuse_instance.py @@ -0,0 +1,61 @@ +"""Tests for reused Markdown instance state isolation (issue #763).""" + +import sys +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) +import markdown2 # noqa: E402 + + +class TestReuseInstanceIsolation(unittest.TestCase): + """Each convert() call on a reused instance must behave like a fresh one.""" + + def test_escape_table_reset_between_calls(self): + """Backslash-escaped text must not be affected by a previous convert().""" + md = markdown2.Markdown() + md.convert("[a link](https://example.com/page)") + result_reused = md.convert(r"Type \https://example.com/page to see it.") + result_fresh = markdown2.Markdown().convert(r"Type \https://example.com/page to see it.") + self.assertEqual(result_reused, result_fresh) + + def test_code_table_reset_between_calls(self): + """Code spans from earlier documents must not leak into later ones.""" + md = markdown2.Markdown() + md.convert("`first_code_span`") + # The hash for first_code_span must not persist and corrupt the new doc + result_reused = md.convert("plain `second_code_span` text") + result_fresh = markdown2.Markdown().convert("plain `second_code_span` text") + self.assertEqual(result_reused, result_fresh) + + def test_performance_does_not_degrade(self): + """Processing time per document must not grow with the number of documents.""" + md = markdown2.Markdown() + doc = "[link{}](https://example.com) `code{}`\n" + + # warm up + for i in range(5): + md.convert(doc.format(i, i)) + + t_start_early = time.monotonic() + for i in range(5, 10): + md.convert(doc.format(i, i)) + t_early = time.monotonic() - t_start_early + + # process 50 more documents to accumulate stale table entries + for i in range(10, 60): + md.convert(doc.format(i, i)) + + t_start_late = time.monotonic() + for i in range(60, 65): + md.convert(doc.format(i, i)) + t_late = time.monotonic() - t_start_late + + # With the fix, late processing is not significantly slower than early + self.assertLess(t_late, t_early * 5, + f"Performance degraded: early={t_early*1000:.1f}ms late={t_late*1000:.1f}ms") + + +if __name__ == "__main__": + unittest.main() From 01cab5a5ab2369053271ea84f13a4315a2880cbd Mon Sep 17 00:00:00 2001 From: HongTin2104 Date: Fri, 6 Nov 2026 08:09:02 +0700 Subject: [PATCH 2/2] test: fix performance test on fast Windows runners where times round to 0 --- test/test_reuse_instance.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/test_reuse_instance.py b/test/test_reuse_instance.py index 19248029..0067de7f 100644 --- a/test/test_reuse_instance.py +++ b/test/test_reuse_instance.py @@ -52,8 +52,10 @@ def test_performance_does_not_degrade(self): md.convert(doc.format(i, i)) t_late = time.monotonic() - t_start_late - # With the fix, late processing is not significantly slower than early - self.assertLess(t_late, t_early * 5, + # With the fix, late processing is not significantly slower than early. + # Use max(..., 1e-5) so the comparison is valid when both times round + # to 0.0 on fast CI runners (Windows). + self.assertLess(t_late, max(t_early, 1e-5) * 10, f"Performance degraded: early={t_early*1000:.1f}ms late={t_late*1000:.1f}ms")