From 7cdfb7c3a72aba0384ebde4faab896d3fe3a6e0d Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 19:45:16 +0800 Subject: [PATCH 01/14] fix: atexit_cleanup the state Signed-off-by: yihong0618 --- Modules/atexitmodule.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/atexitmodule.c b/Modules/atexitmodule.c index 4b068967a6ca6e..5ab21c139fe4cd 100644 --- a/Modules/atexitmodule.c +++ b/Modules/atexitmodule.c @@ -112,6 +112,8 @@ atexit_callfuncs(struct atexit_state *state) { PyErr_FormatUnraisable("Exception ignored while " "copying atexit callbacks"); + // gh-140080: need to cleanup + atexit_cleanup(state); return; } From da72a0b8a827d14bc0d121e2516414a3ee7991c7 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 20:44:24 +0800 Subject: [PATCH 02/14] fix: add news and tests Signed-off-by: yihong0618 --- Lib/test/test_exceptions.py | 26 +++++++++++++++++++ ...-10-14-20-18-31.gh-issue-140080.8ROjxW.rst | 2 ++ Modules/atexitmodule.c | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 323a8c401bde6c..60fa1f2a458f5d 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -342,6 +342,32 @@ def testMemoryErrorBigSource(self, size): with self.assertRaisesRegex(OverflowError, "Parser column offset overflow"): compile(src, '', 'exec') + @cpython_only + # Python built with Py_TRACE_REFS fail with a fatal error in + # _PyRefchain_Trace() on memory allocation error. + @unittest.skipIf(support.Py_TRACE_REFS, 'cannot test Py_TRACE_REFS build') + def test_atexit_with_low_memory(self): + # gh-140080: Test that setting low memory after registering an atexit + # callback doesn't cause an infinite loop during finalization. + user_input = dedent(""" + import atexit + import _testcapi + + def callback(): + pass + + atexit.register(callback) + # Simulate low memory condition + _testcapi.set_nomemory(0) + """) + with SuppressCrashReport(): + with script_helper.spawn_python('-c', user_input) as p: + p.wait() + output = p.stdout.read() + + # The key point is that the process should exit (not hang) + self.assertIn(p.returncode, (0, 1)) + @cpython_only def testSettingException(self): # test that setting an exception at the C level works even if the diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst new file mode 100644 index 00000000000000..4812ca13ea3777 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst @@ -0,0 +1,2 @@ +Fix: ``atexit_callfuncs`` need to atexit_cleanup the state when copy is NULL +to avoid the low memory error then recursive error. diff --git a/Modules/atexitmodule.c b/Modules/atexitmodule.c index 5ab21c139fe4cd..abd382a42d2043 100644 --- a/Modules/atexitmodule.c +++ b/Modules/atexitmodule.c @@ -112,7 +112,7 @@ atexit_callfuncs(struct atexit_state *state) { PyErr_FormatUnraisable("Exception ignored while " "copying atexit callbacks"); - // gh-140080: need to cleanup + // gh-140080: need to cleanup to prevent recursive when low memory atexit_cleanup(state); return; } From 0f0cbef8c29c5de354e0415495d5bcb3c27f048a Mon Sep 17 00:00:00 2001 From: yihong Date: Tue, 14 Oct 2025 22:17:57 +0800 Subject: [PATCH 03/14] Apply suggestions from code review Co-authored-by: Peter Bierma --- .../2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst | 3 +-- Modules/atexitmodule.c | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst index 4812ca13ea3777..0ddcea57f9d5b6 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-20-18-31.gh-issue-140080.8ROjxW.rst @@ -1,2 +1 @@ -Fix: ``atexit_callfuncs`` need to atexit_cleanup the state when copy is NULL -to avoid the low memory error then recursive error. +Fix hang during finalization when attempting to call :mod:`atexit` handlers under no memory. diff --git a/Modules/atexitmodule.c b/Modules/atexitmodule.c index abd382a42d2043..4536b03fbc4de9 100644 --- a/Modules/atexitmodule.c +++ b/Modules/atexitmodule.c @@ -112,7 +112,6 @@ atexit_callfuncs(struct atexit_state *state) { PyErr_FormatUnraisable("Exception ignored while " "copying atexit callbacks"); - // gh-140080: need to cleanup to prevent recursive when low memory atexit_cleanup(state); return; } From d66439393ffb1d321873d4a8d12df44a901bddb6 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 22:30:21 +0800 Subject: [PATCH 04/14] fix: move tests to atexit Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 28 +++++++++++++++++++++++++++- Lib/test/test_exceptions.py | 25 ------------------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 66142a108d5d93..1831cbec5e00bc 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -1,9 +1,10 @@ import atexit import os +import subprocess import textwrap import unittest from test import support -from test.support import script_helper +from test.support import SuppressCrashReport, script_helper from test.support import threading_helper class GeneralTest(unittest.TestCase): @@ -189,6 +190,31 @@ def callback(): self.assertEqual(os.read(r, len(expected)), expected) os.close(r) + # Python built with Py_TRACE_REFS fail with a fatal error in + # _PyRefchain_Trace() on memory allocation error. + @unittest.skipIf(support.Py_TRACE_REFS, 'cannot test Py_TRACE_REFS build') + def test_atexit_with_low_memory(self): + # gh-140080: Test that setting low memory after registering an atexit + # callback doesn't cause an infinite loop during finalization. + user_input = textwrap.dedent(""" + import atexit + import _testcapi + + def callback(): + pass + + atexit.register(callback) + # Simulate low memory condition + _testcapi.set_nomemory(0) + """) + with SuppressCrashReport(): + with script_helper.spawn_python('-c', user_input, + stderr=subprocess.PIPE) as p: + p.wait() + p.stdout.read() + + self.assertIn(p.returncode, (0, 1)) + if __name__ == "__main__": unittest.main() diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 60fa1f2a458f5d..7dfbeb19b09523 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -342,31 +342,6 @@ def testMemoryErrorBigSource(self, size): with self.assertRaisesRegex(OverflowError, "Parser column offset overflow"): compile(src, '', 'exec') - @cpython_only - # Python built with Py_TRACE_REFS fail with a fatal error in - # _PyRefchain_Trace() on memory allocation error. - @unittest.skipIf(support.Py_TRACE_REFS, 'cannot test Py_TRACE_REFS build') - def test_atexit_with_low_memory(self): - # gh-140080: Test that setting low memory after registering an atexit - # callback doesn't cause an infinite loop during finalization. - user_input = dedent(""" - import atexit - import _testcapi - - def callback(): - pass - - atexit.register(callback) - # Simulate low memory condition - _testcapi.set_nomemory(0) - """) - with SuppressCrashReport(): - with script_helper.spawn_python('-c', user_input) as p: - p.wait() - output = p.stdout.read() - - # The key point is that the process should exit (not hang) - self.assertIn(p.returncode, (0, 1)) @cpython_only def testSettingException(self): From b9af1025c5a3747ca7355dd469df2d49f7723f31 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 22:31:32 +0800 Subject: [PATCH 05/14] fix: empty line drop Signed-off-by: yihong0618 --- Lib/test/test_exceptions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 7dfbeb19b09523..323a8c401bde6c 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -342,7 +342,6 @@ def testMemoryErrorBigSource(self, size): with self.assertRaisesRegex(OverflowError, "Parser column offset overflow"): compile(src, '', 'exec') - @cpython_only def testSettingException(self): # test that setting an exception at the C level works even if the From 2e2e467ed240916b128c931fe49fbdf6e0fc44d8 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 22:33:21 +0800 Subject: [PATCH 06/14] fix: use assert not in instead Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 1831cbec5e00bc..b7d2f31efb386e 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -201,6 +201,7 @@ def test_atexit_with_low_memory(self): import _testcapi def callback(): + print("hello") pass atexit.register(callback) @@ -211,9 +212,10 @@ def callback(): with script_helper.spawn_python('-c', user_input, stderr=subprocess.PIPE) as p: p.wait() - p.stdout.read() + output = p.stdout.read() self.assertIn(p.returncode, (0, 1)) + self.assertNotIn(b"hello", output) if __name__ == "__main__": From 60d200e4074adef883bd9e139d60049d0c45078f Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 22:42:37 +0800 Subject: [PATCH 07/14] fix: address comments Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index b7d2f31efb386e..b020a2b61e366d 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -202,7 +202,6 @@ def test_atexit_with_low_memory(self): def callback(): print("hello") - pass atexit.register(callback) # Simulate low memory condition @@ -212,10 +211,13 @@ def callback(): with script_helper.spawn_python('-c', user_input, stderr=subprocess.PIPE) as p: p.wait() - output = p.stdout.read() + stdout = p.stdout.read() + stderr = p.stderr.read() self.assertIn(p.returncode, (0, 1)) - self.assertNotIn(b"hello", output) + self.assertNotIn(b"hello", stdout) + # MemoryError should appear in stderr + self.assertIn(b"MemoryError", stderr) if __name__ == "__main__": From 8d3c3a8ba4b1ab02440a2dbf815d23d68b50a8ce Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 22:49:47 +0800 Subject: [PATCH 08/14] fix: use temp here Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index b020a2b61e366d..1e8f0629029b20 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -1,6 +1,8 @@ import atexit import os import subprocess +import sys +import tempfile import textwrap import unittest from test import support @@ -196,7 +198,7 @@ def callback(): def test_atexit_with_low_memory(self): # gh-140080: Test that setting low memory after registering an atexit # callback doesn't cause an infinite loop during finalization. - user_input = textwrap.dedent(""" + code = textwrap.dedent(""" import atexit import _testcapi @@ -207,17 +209,25 @@ def callback(): # Simulate low memory condition _testcapi.set_nomemory(0) """) - with SuppressCrashReport(): - with script_helper.spawn_python('-c', user_input, - stderr=subprocess.PIPE) as p: - p.wait() - stdout = p.stdout.read() - stderr = p.stderr.read() - - self.assertIn(p.returncode, (0, 1)) - self.assertNotIn(b"hello", stdout) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + f.write(code) + script = f.name + + try: + with SuppressCrashReport(): + proc = subprocess.run( + [sys.executable, script], + capture_output=True, + timeout=10 + ) + finally: + os.unlink(script) + + self.assertIn(proc.returncode, (0, 1)) + self.assertNotIn(b"hello", proc.stdout) # MemoryError should appear in stderr - self.assertIn(b"MemoryError", stderr) + self.assertIn(b"MemoryError", proc.stderr) if __name__ == "__main__": From 5e288a2681327be32c525347a9fdcc754f34a0cd Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 14 Oct 2025 23:01:23 +0800 Subject: [PATCH 09/14] fix: ingore not requires_subprocess Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 1e8f0629029b20..41f436d6635572 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -195,6 +195,7 @@ def callback(): # Python built with Py_TRACE_REFS fail with a fatal error in # _PyRefchain_Trace() on memory allocation error. @unittest.skipIf(support.Py_TRACE_REFS, 'cannot test Py_TRACE_REFS build') + @support.requires_subprocess() def test_atexit_with_low_memory(self): # gh-140080: Test that setting low memory after registering an atexit # callback doesn't cause an infinite loop during finalization. @@ -226,7 +227,6 @@ def callback(): self.assertIn(proc.returncode, (0, 1)) self.assertNotIn(b"hello", proc.stdout) - # MemoryError should appear in stderr self.assertIn(b"MemoryError", proc.stderr) From 42f90ab2188142ded5f59f0816f20c09518fd8ff Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 15 Oct 2025 07:14:27 +0800 Subject: [PATCH 10/14] fix: use spawn_python Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 41f436d6635572..3f9bb9b04e6b31 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -1,7 +1,6 @@ import atexit import os import subprocess -import sys import tempfile import textwrap import unittest @@ -195,7 +194,6 @@ def callback(): # Python built with Py_TRACE_REFS fail with a fatal error in # _PyRefchain_Trace() on memory allocation error. @unittest.skipIf(support.Py_TRACE_REFS, 'cannot test Py_TRACE_REFS build') - @support.requires_subprocess() def test_atexit_with_low_memory(self): # gh-140080: Test that setting low memory after registering an atexit # callback doesn't cause an infinite loop during finalization. @@ -217,17 +215,17 @@ def callback(): try: with SuppressCrashReport(): - proc = subprocess.run( - [sys.executable, script], - capture_output=True, - timeout=10 - ) + with script_helper.spawn_python(script, + stderr=subprocess.PIPE) as proc: + proc.wait() + stdout = proc.stdout.read() + stderr = proc.stderr.read() finally: os.unlink(script) self.assertIn(proc.returncode, (0, 1)) - self.assertNotIn(b"hello", proc.stdout) - self.assertIn(b"MemoryError", proc.stderr) + self.assertNotIn(b"hello", stdout) + self.assertIn(b"MemoryError", stderr) if __name__ == "__main__": From 294e404af602c1a745988f93241ff0182d627ff7 Mon Sep 17 00:00:00 2001 From: yihong Date: Wed, 15 Oct 2025 07:48:52 +0800 Subject: [PATCH 11/14] Update Lib/test/test_atexit.py Co-authored-by: Victor Stinner --- Lib/test/test_atexit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 3f9bb9b04e6b31..36dfdf8b6b7141 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -216,7 +216,7 @@ def callback(): try: with SuppressCrashReport(): with script_helper.spawn_python(script, - stderr=subprocess.PIPE) as proc: + stderr=subprocess.PIPE) as proc: proc.wait() stdout = proc.stdout.read() stderr = proc.stderr.read() From 1958a7a69b0d75ead45c4099e611e714c5952644 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 15 Oct 2025 07:50:38 +0800 Subject: [PATCH 12/14] fix: apply comments Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 36dfdf8b6b7141..12dc770468cb69 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -209,19 +209,17 @@ def callback(): _testcapi.set_nomemory(0) """) - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f: f.write(code) + f.flush() script = f.name - try: with SuppressCrashReport(): with script_helper.spawn_python(script, stderr=subprocess.PIPE) as proc: proc.wait() stdout = proc.stdout.read() stderr = proc.stderr.read() - finally: - os.unlink(script) self.assertIn(proc.returncode, (0, 1)) self.assertNotIn(b"hello", stdout) From 7ab02316837f9b86909ba43b607952fb0773e929 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 15 Oct 2025 07:54:28 +0800 Subject: [PATCH 13/14] fix: indentation level for test code in test Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 12dc770468cb69..20d14b4258663a 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -198,15 +198,15 @@ def test_atexit_with_low_memory(self): # gh-140080: Test that setting low memory after registering an atexit # callback doesn't cause an infinite loop during finalization. code = textwrap.dedent(""" - import atexit - import _testcapi + import atexit + import _testcapi - def callback(): - print("hello") + def callback(): + print("hello") - atexit.register(callback) - # Simulate low memory condition - _testcapi.set_nomemory(0) + atexit.register(callback) + # Simulate low memory condition + _testcapi.set_nomemory(0) """) with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f: From 00b5a8c5eb744cb6d04a62ca461740dcb710f522 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 15 Oct 2025 08:51:59 +0800 Subject: [PATCH 14/14] fix: use test helper function Signed-off-by: yihong0618 --- Lib/test/test_atexit.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_atexit.py b/Lib/test/test_atexit.py index 20d14b4258663a..8256ff183f28c9 100644 --- a/Lib/test/test_atexit.py +++ b/Lib/test/test_atexit.py @@ -1,11 +1,11 @@ import atexit import os import subprocess -import tempfile import textwrap import unittest from test import support from test.support import SuppressCrashReport, script_helper +from test.support import os_helper from test.support import threading_helper class GeneralTest(unittest.TestCase): @@ -209,11 +209,8 @@ def callback(): _testcapi.set_nomemory(0) """) - with tempfile.NamedTemporaryFile(mode='w', suffix='.py') as f: - f.write(code) - f.flush() - script = f.name - + with os_helper.temp_dir() as temp_dir: + script = script_helper.make_script(temp_dir, 'test_atexit_script', code) with SuppressCrashReport(): with script_helper.spawn_python(script, stderr=subprocess.PIPE) as proc: