diff --git a/Include/internal/pycore_pythonrun.h b/Include/internal/pycore_pythonrun.h index d333eb2ccf7c41..eb4d7360084983 100644 --- a/Include/internal/pycore_pythonrun.h +++ b/Include/internal/pycore_pythonrun.h @@ -63,6 +63,20 @@ extern PyObject* _PyRun_SimpleString( #endif #ifdef _Py_THREAD_SANITIZER +/* TSan: tstate_set_stack() only uses half the stack, so the 6-margin + * floor that is enough for ASan/debug still leaves Thread bootstrap + * without working space (gh-141044). Require 12 margins. */ +# define _PyOS_MIN_STACK_SIZE (_PyOS_STACK_MARGIN_BYTES * 12) +#elif (defined(Py_DEBUG) \ + || defined(_Py_ADDRESS_SANITIZER) \ + || defined(_Py_UNDEFINED_BEHAVIOR_SANITIZER)) +/* Debug/ASan/UBSan need more than the default 3 margins: + * - ASan (gh-141044): instrumentation consumes extra C stack. + * - Py_DEBUG / UBSan: larger C frames leave threading.Thread bootstrap + * with no working space above the soft recursion limit at 3 margins, + * leaking thread objects (same failure mode as ASan). + * Require 6 margins, matching the builds that already use a larger + * _PyOS_LOG2_STACK_MARGIN above. Release builds stay at 3. */ # define _PyOS_MIN_STACK_SIZE (_PyOS_STACK_MARGIN_BYTES * 6) #else # define _PyOS_MIN_STACK_SIZE (_PyOS_STACK_MARGIN_BYTES * 3) @@ -73,4 +87,3 @@ extern PyObject* _PyRun_SimpleString( } #endif #endif // !Py_INTERNAL_PYTHONRUN_H - diff --git a/Lib/test/test_thread.py b/Lib/test/test_thread.py index ac924728febc99..86cb8990d6cbad 100644 --- a/Lib/test/test_thread.py +++ b/Lib/test/test_thread.py @@ -84,6 +84,12 @@ def test_stack_size(self): # size must be positive thread.stack_size(-4096) + if support.check_sanitizer(address=True, function=False): + # gh-141044: 127 KiB used to be accepted but leaked under ASan + with self.assertRaises(ValueError): + thread.stack_size(127 * 1024) + self.assertEqual(thread.stack_size(), 0) + @unittest.skipIf(os.name not in ("nt", "posix"), 'test meant for nt and posix') def test_nt_and_posix_stack_size(self): try: @@ -96,12 +102,22 @@ def test_nt_and_posix_stack_size(self): "size") fail_msg = "stack_size(%d) failed - should succeed" + # 256 KiB may be below the sanitizer minimum (gh-141044 / TSan). + tested = [] for tss in (262144, 0x100000, 0): - thread.stack_size(tss) + try: + thread.stack_size(tss) + except ValueError: + verbose_print("skipping stack_size(%d); below platform minimum" + % tss) + continue self.assertEqual(thread.stack_size(), tss, fail_msg % tss) verbose_print("successfully set stack_size(%d)" % tss) + tested.append(tss) for tss in (262144, 0x100000): + if tss not in tested: + continue verbose_print("trying stack_size = (%d)" % tss) self.next_ident = 0 self.created = 0 diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py index 96b43936be92cd..ae8f0faf122c68 100644 --- a/Lib/test/test_threading.py +++ b/Lib/test/test_threading.py @@ -245,17 +245,19 @@ def f(): done.wait() self.assertEqual(ident[0], tid) - # run with a small(ish) thread stack size (256 KiB) + # run with a small(ish) thread stack size (512 KiB) def test_various_ops_small_stack(self): if verbose: - print('with 256 KiB thread stack size...') + print('with 512 KiB thread stack size...') try: - threading.stack_size(262144) - except _thread.error: + threading.stack_size(512 * 1024) + except (ValueError, _thread.error): raise unittest.SkipTest( - 'platform does not support changing thread stack size') - self.test_various_ops() - threading.stack_size(0) + 'platform does not support a 512 KiB thread stack') + try: + self.test_various_ops() + finally: + threading.stack_size(0) # run with a large thread stack size (1 MiB) def test_various_ops_large_stack(self): @@ -269,6 +271,140 @@ def test_various_ops_large_stack(self): self.test_various_ops() threading.stack_size(0) + def test_stack_size_no_leak(self): + # gh-141044: a custom thread stack size used to leak Thread objects + # when the size passed _thread.stack_size() but was too small for + # threading.Thread bootstrap under AddressSanitizer. The reported + # repro used 127 KiB and did not join the thread. + try: + threading.stack_size(0x100000) + except (ValueError, _thread.error): + self.skipTest( + 'platform does not support a 1 MiB thread stack') + threading.stack_size(0) + + def run_script(script): + _, _, err = assert_python_ok( + "-c", textwrap.dedent(script), + ASAN_OPTIONS="detect_leaks=1:halt_on_error=1") + err_s = err.decode("utf-8", "replace") + self.assertNotIn("LeakSanitizer", err_s, err_s) + + min_stack_helper = """ + import os + import threading + import _thread + + def min_stack_size(): + page = os.sysconf("SC_PAGESIZE") if hasattr(os, "sysconf") else 4096 + size = page + while size <= 4 * 1024 * 1024: + try: + threading.stack_size(size) + except ValueError: + size += page + continue + except _thread.error: + return None + return size + return None + """ + + # Original reproducer: start, do not join. 127 KiB is rejected on + # ASan builds; if a build still accepts it, the thread must not leak. + run_script(""" + import threading + try: + threading.stack_size(127 * 1024) + except ValueError: + raise SystemExit(0) + def worker(): + pass + t = threading.Thread(target=worker, name="worker-thread") + t.start() + threading.stack_size(0) + """) + + # Smallest accepted size, unjoined. Wait until the worker finishes + # without join(); process shutdown also joins. LSan plus (on debug + # builds) gettotalrefcount() must stay clean at this new minimum. + run_script(min_stack_helper + """ + import gc + import sys + import time + + def worker(): + pass + size = min_stack_size() + if size is None: + raise SystemExit(0) + + def wait_unjoined(threads, timeout=30): + deadline = time.monotonic() + timeout + for t in threads: + while t.is_alive(): + if time.monotonic() > deadline: + raise SystemExit("unjoined worker did not finish") + time.sleep(0.001) + + if hasattr(sys, "gettotalrefcount"): + gc.collect() + gc.collect() + start = sys.gettotalrefcount() + threads = [] + for _ in range(8): + t = threading.Thread(target=worker) + t.start() + threads.append(t) + wait_unjoined(threads) + del threads + threading.stack_size(0) + gc.collect() + gc.collect() + delta = sys.gettotalrefcount() - start + if delta > 50: + raise SystemExit(f"refcount leak: {delta}") + else: + t = threading.Thread(target=worker, name="min-stack-worker") + t.start() + threading.stack_size(0) + """) + + # Joined threads at the minimum must not leak references. + run_script(min_stack_helper + """ + import gc + import sys + + def worker(): + pass + size = min_stack_size() + if size is None: + raise SystemExit(0) + for _ in range(3): + t = threading.Thread(target=worker) + t.start() + t.join() + if hasattr(sys, "gettotalrefcount"): + gc.collect() + gc.collect() + start = sys.gettotalrefcount() + for _ in range(8): + t = threading.Thread(target=worker) + t.start() + t.join() + threading.stack_size(0) + gc.collect() + gc.collect() + delta = sys.gettotalrefcount() - start + if delta > 50: + raise SystemExit(f"refcount leak: {delta}") + else: + t = threading.Thread(target=worker) + t.start() + t.join() + threading.stack_size(0) + """) + def test_foreign_thread(self): # Check that a "foreign" thread can use the threading module. dummy_thread = None diff --git a/Misc/NEWS.d/next/Library/2026-09-14-20-45-00.gh-issue-141044.Ks8nQm.rst b/Misc/NEWS.d/next/Library/2026-09-14-20-45-00.gh-issue-141044.Ks8nQm.rst new file mode 100644 index 00000000000000..8f3adc8c70d696 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-14-20-45-00.gh-issue-141044.Ks8nQm.rst @@ -0,0 +1,8 @@ +Fix a reference leak when creating a :class:`threading.Thread` after +setting a small custom stack size with :func:`threading.stack_size` in +debug and sanitizer builds. Those builds need extra C stack, so +:func:`!_thread.stack_size` now requires 6 stack margins on debug/ASan/UBSan +builds and 12 on ThreadSanitizer builds (which only use half the stack). +Sizes that previously appeared to work (for example 127 KiB on 64-bit +debug/ASan builds) now raise :exc:`ValueError`. Release builds are +unchanged. Patch by Kailash Nelson.