Skip to content

Commit 5b76676

Browse files
committed
Add free-threaded thread-stress tests and 3.14t to CI matrix
tests/test_thread.py: - test_runtime_refcount_matches_sys_getrefcount and test_is_gil_enabled_attribute_present_on_3_13_plus assert the basic invariants behind ABI.DetectFreeThreaded and Runtime.Refcount. - test_concurrent_clr_method_calls and test_concurrent_attribute_access exercise the CLR call site cache and the ConcurrentDictionary intern path under contention. Both run on every interpreter; on GIL builds they degenerate to mostly-serial smoke checks. - test_concurrent_clr_object_creation, test_concurrent_python_subclass_of_clr_type and test_freethreaded_concurrent_attribute_access_no_tear are FT-only because the GIL-build code path triggers a pre-existing pythonnet crash under high-contention CLR allocation that is reproducible on master and out of scope for this branch. .github/workflows/main.yml: - Add 3.14t to the python matrix on Linux and macOS (Windows FT support is not yet plumbed through pythonnet's native build chain). - Skip the Mono runtime steps on 3.14t — clr-loader's mono backend is not yet validated for free-threaded Python.
1 parent 2f08c98 commit 5b76676

2 files changed

Lines changed: 157 additions & 3 deletions

File tree

‎.github/workflows/main.yml‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
instance: macos-15
4747
suffix: -macos-aarch64-none
4848

49-
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
49+
python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]
5050

5151
exclude:
5252
# Fails with initfs_encoding error
@@ -67,6 +67,17 @@ jobs:
6767
platform: x86
6868
python: '3.13'
6969

70+
# Free-threaded Python on Windows is not yet supported by pythonnet's
71+
# native build chain; restrict 3.14t to Linux and macOS for now.
72+
- os:
73+
category: windows
74+
platform: x86
75+
python: '3.14t'
76+
- os:
77+
category: windows
78+
platform: x64
79+
python: '3.14t'
80+
7081
env:
7182
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
7283
steps:
@@ -99,7 +110,8 @@ jobs:
99110
run: uv sync --managed-python
100111

101112
- name: Embedding tests (Mono/.NET Framework)
102-
if: always()
113+
# Mono does not yet support free-threaded Python.
114+
if: ${{ always() && matrix.python != '3.14t' }}
103115
run: dotnet test --runtime any-${{ matrix.os.platform }} --framework net472 --logger "console;verbosity=detailed" src/embed_tests/
104116
env:
105117
MONO_THREADS_SUSPEND: preemptive # https://github.com/mono/mono/issues/21466
@@ -113,7 +125,8 @@ jobs:
113125
run: pytest --runtime coreclr
114126

115127
- name: Python Tests (Mono)
116-
if: always()
128+
# Mono does not yet support free-threaded Python.
129+
if: ${{ always() && matrix.python != '3.14t' }}
117130
run: pytest --runtime mono
118131

119132
- name: Python Tests (.NET Framework)

‎tests/test_thread.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,30 @@
22

33
"""Test CLR bridge threading and GIL handling."""
44

5+
import sys
56
import threading
67
import time
78

9+
import pytest
10+
811
import _thread as thread
912
from .utils import dprint
1013

1114

15+
def _gil_enabled():
16+
"""True on every CPython that has a GIL. Always True before 3.13."""
17+
return getattr(sys, "_is_gil_enabled", lambda: True)()
18+
19+
20+
# Marker: tests that only matter on free-threaded builds. Skipped on GIL
21+
# builds because the GIL serialises Python-level operations and most of the
22+
# concurrency hazards we want to exercise simply cannot fire.
23+
freethreaded_only = pytest.mark.skipif(
24+
_gil_enabled(),
25+
reason="Only meaningful on free-threaded Python (Py_GIL_DISABLED).",
26+
)
27+
28+
1229
def test_simple_callback_to_python():
1330
"""Test a call to managed code that then calls back into Python."""
1431
from Python.Test import ThreadTest
@@ -56,3 +73,127 @@ def start_threads(count):
5673
while len(done) < 50:
5774
dprint(len(done))
5875
time.sleep(0.1)
76+
77+
78+
# Free-threaded / refcount tests below. Run on every interpreter; the GIL
79+
# builds exercise the same code paths in single-threaded form while the FT
80+
# builds (Py_GIL_DISABLED) actually stress the concurrent paths.
81+
82+
83+
def test_runtime_refcount_matches_sys_getrefcount():
84+
"""Refcount tracks sys.getrefcount on both GIL and FT builds."""
85+
obj = object()
86+
rc_before = sys.getrefcount(obj)
87+
extra = [obj, obj, obj]
88+
assert sys.getrefcount(obj) - rc_before == 3
89+
del extra
90+
91+
92+
def test_is_gil_enabled_attribute_present_on_3_13_plus():
93+
"""sys._is_gil_enabled is present from 3.13 — used by ABI.DetectFreeThreaded."""
94+
if sys.version_info < (3, 13):
95+
assert not hasattr(sys, "_is_gil_enabled")
96+
else:
97+
assert isinstance(sys._is_gil_enabled(), bool)
98+
99+
100+
def _run_in_threads(target, n_threads, *args, **kwargs):
101+
"""Run target() in n_threads threads, return results in start order, raise on first error."""
102+
results = [None] * n_threads
103+
errors = [None] * n_threads
104+
105+
def worker(i):
106+
try:
107+
results[i] = target(i, *args, **kwargs)
108+
except BaseException as e:
109+
errors[i] = e
110+
111+
threads = [threading.Thread(target=worker, args=(i,)) for i in range(n_threads)]
112+
for t in threads:
113+
t.start()
114+
for t in threads:
115+
t.join()
116+
for e in errors:
117+
if e is not None:
118+
raise e
119+
return results
120+
121+
122+
def test_concurrent_clr_method_calls():
123+
"""Concurrent CLR method invocation across threads."""
124+
from Python.Test import ThreadTest
125+
126+
def call(_):
127+
return [ThreadTest.CallEchoString("ping") for _ in range(200)]
128+
129+
for r in _run_in_threads(call, n_threads=8):
130+
assert all(x == "ping" for x in r)
131+
132+
133+
def test_concurrent_attribute_access():
134+
"""Concurrent attribute access — exercises the ConcurrentDictionary InternString cache."""
135+
import System
136+
from System.Collections.Generic import List
137+
138+
def access(_):
139+
for _ in range(500):
140+
_ = System.String.Empty
141+
_ = System.Int32.MaxValue
142+
_ = List[int]
143+
_ = List[str]
144+
return True
145+
146+
assert all(_run_in_threads(access, n_threads=8))
147+
148+
149+
@freethreaded_only
150+
def test_concurrent_clr_object_creation():
151+
"""Concurrent CLR object alloc/free — exercises reflectedObjects + loadedExtensions.
152+
153+
FT-only: under the GIL this high-contention pattern hits a pre-existing
154+
pythonnet crash (also reproducible on master) outside this branch's scope.
155+
"""
156+
from System.Collections.Generic import List
157+
158+
LI = List[int]
159+
160+
def make_lists(_):
161+
for _ in range(200):
162+
l = LI()
163+
for j in range(5):
164+
l.Add(j)
165+
assert l.Count == 5
166+
return True
167+
168+
assert all(_run_in_threads(make_lists, n_threads=8))
169+
170+
171+
@freethreaded_only
172+
def test_concurrent_python_subclass_of_clr_type():
173+
"""Concurrent dynamic-subclass creation — exercises ClassDerived's builder lock.
174+
175+
FT-only for the same reason as test_concurrent_clr_object_creation.
176+
"""
177+
import System
178+
179+
def derive(i):
180+
cls = type(f"Derived_{i}_{threading.get_ident()}", (System.Object,), {})
181+
cls()
182+
return cls.__name__
183+
184+
names = _run_in_threads(derive, n_threads=8)
185+
assert len(set(names)) == len(names)
186+
187+
188+
@freethreaded_only
189+
def test_freethreaded_concurrent_attribute_access_no_tear():
190+
"""Heavier attribute-access stress to bias scheduling toward racy reads."""
191+
import System
192+
193+
def stress(_):
194+
for _ in range(2000):
195+
_ = System.String.Empty
196+
_ = System.Int32.MaxValue
197+
return True
198+
199+
assert all(_run_in_threads(stress, n_threads=16))

0 commit comments

Comments
 (0)