Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
00fe666
gh-141044: Fix ASan leak with small threading.stack_size()
KingLizard1020 Sep 17, 2026
3aef310
gh-141044: Require 6 stack margins on debug builds too
KingLizard1020 Sep 21, 2026
09dc0cb
gh-141044: Update NEWS for debug/sanitizer 6-margin floor
KingLizard1020 Sep 21, 2026
04ebedb
gh-141044: Portable 127 KiB stack_size rejection check
KingLizard1020 Sep 21, 2026
bfbf64c
gh-141044: Restore test_thread.py (undo accidental PLACEHOLDER)
KingLizard1020 Sep 21, 2026
7f942ca
gh-141044: Silence NEWS Sphinx nit for _thread.stack_size
KingLizard1020 Sep 21, 2026
1fac29a
gh-141044: Raise TSan stack_size floor to 12 margins
KingLizard1020 Sep 21, 2026
0f0971d
gh-141044: Update NEWS for TSan 12-margin stack_size floor
KingLizard1020 Sep 21, 2026
f2242a1
gh-141044: Allow stack_size tests to skip 256 KiB under TSan
KingLizard1020 Sep 21, 2026
4d72b7e
gh-141044: Skip 256 KiB stack tests when below TSan floor
KingLizard1020 Sep 21, 2026
c15bbbd
temp: canary for restore path (will remove)
KingLizard1020 Sep 21, 2026
ab3acf1
temp: escape-test canary (will remove)
KingLizard1020 Sep 21, 2026
c43e6b8
wip: restore test_threading.py (5KB partial)
KingLizard1020 Sep 21, 2026
d012c29
temp: remove restore canary
KingLizard1020 Sep 21, 2026
12213d2
test @file
KingLizard1020 Sep 21, 2026
bb6fe83
wip: restore test_threading.py (5KB partial, undo @path probe)
KingLizard1020 Sep 21, 2026
124b140
gh-141044: Fix Docs soft-deprecated KeyError in changes builder
KingLizard1020 Sep 21, 2026
f609ce4
Add cpy test_threading restore part _cpy_tt_part_0.b64 (no trigger)
KingLizard1020 Sep 21, 2026
fe78d4b
Add cpy test_threading restore part _cpy_tt_part_1.b64 (no trigger)
KingLizard1020 Sep 21, 2026
4eb90e5
Fix part1 chunk0 of cpy test_threading restore
KingLizard1020 Sep 21, 2026
6222f46
Restore threading regression tests and update from main
KingLizard1020 Sep 22, 2026
de3886e
Fix threading tests for sanitizer stack minimum
KingLizard1020 Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Include/internal/pycore_pythonrun.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -73,4 +87,3 @@ extern PyObject* _PyRun_SimpleString(
}
#endif
#endif // !Py_INTERNAL_PYTHONRUN_H

18 changes: 17 additions & 1 deletion Lib/test/test_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
150 changes: 143 additions & 7 deletions Lib/test/test_threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading