diff --git a/Lib/test/test_json/test_recursion.py b/Lib/test/test_json/test_recursion.py index ffd3404e6f77a0..ba89c76cd3f6be 100644 --- a/Lib/test/test_json/test_recursion.py +++ b/Lib/test/test_json/test_recursion.py @@ -1,3 +1,5 @@ +import weakref +import sys from test import support from test.test_json import PyTest, CTest @@ -5,7 +7,6 @@ class JSONTestObject: pass - class TestRecursion: def test_listrecursion(self): x = [] @@ -115,6 +116,36 @@ def default(self, o): with support.infinite_recursion(1000): EndlessJSONEncoder(check_circular=False).encode(5j) + @support.skip_if_unlimited_stack_size + @support.skip_emscripten_stack_overflow() + @support.skip_wasi_stack_overflow() + def test_memory_leak_on_recursion_error(self): + # Test that no memory leak occurs when a RecursionError is raised. + class LeakTestObj: + pass + + weak_refs = [] + def default(obj): + if isinstance(obj, LeakTestObj): + new_obj = LeakTestObj() + weak_refs.append(weakref.ref(new_obj)) + return [new_obj] + raise TypeError + + depth = min(500, sys.getrecursionlimit() - 10) + obj = LeakTestObj() + for _ in range(depth): + obj = [obj] + + with support.infinite_recursion(): + with self.assertRaises(RecursionError): + self.dumps(obj, default=default) + + support.gc_collect() + self.assertTrue(weak_refs, "No objects were created to track") + for i, ref in enumerate(weak_refs): + self.assertIsNone(ref(), f"object {i} still alive") + class TestPyRecursion(TestRecursion, PyTest): pass class TestCRecursion(TestRecursion, CTest): pass diff --git a/Misc/NEWS.d/next/Library/2026-03-22-15-25-27.gh-issue-146152.jdQhly.rst b/Misc/NEWS.d/next/Library/2026-03-22-15-25-27.gh-issue-146152.jdQhly.rst new file mode 100644 index 00000000000000..f358e286ba0f9c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-22-15-25-27.gh-issue-146152.jdQhly.rst @@ -0,0 +1,5 @@ +Fix a memory leak in the :mod:`json` module when a RecursionError occurs +during encoding. Previously, objects created in the ``default()`` function +while recursively encoding JSON could remain alive due to being tracked +indefinitely in the encoder's internal circular-reference dictionary. This +change ensures such objects are properly freed after the exception. diff --git a/Modules/_json.c b/Modules/_json.c index cbede8f44dc065..16fdd7b24c2296 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1632,8 +1632,11 @@ encoder_listencode_obj(PyEncoderObject *s, PyUnicodeWriter *writer, } if (_Py_EnterRecursiveCall(" while encoding a JSON object")) { - Py_DECREF(newobj); + if (ident != NULL) { + (void)PyDict_DelItem(s->markers, ident); + } Py_XDECREF(ident); + Py_DECREF(newobj); return -1; } rv = encoder_listencode_obj(s, writer, newobj, indent_level, indent_cache); @@ -1642,12 +1645,15 @@ encoder_listencode_obj(PyEncoderObject *s, PyUnicodeWriter *writer, Py_DECREF(newobj); if (rv) { _PyErr_FormatNote("when serializing %T object", obj); + if (ident != NULL) { + (void)PyDict_DelItem(s->markers, ident); + } Py_XDECREF(ident); return -1; } if (ident != NULL) { if (PyDict_DelItem(s->markers, ident)) { - Py_XDECREF(ident); + Py_DECREF(ident); return -1; } Py_XDECREF(ident);