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
3e8aea4
Thread-safety prep for free-threading builds
greateggsgreg May 9, 2026
ca4b4dc
Initialise pythonnet on free-threaded Python (#2720)
greateggsgreg May 9, 2026
69f6766
Make extension/CLR-object registries thread-safe
greateggsgreg May 9, 2026
a27fbba
Atomic type creation in ReflectedClrType.GetOrCreate / TypeManager.Ge…
greateggsgreg May 9, 2026
c391fd4
Add free-threaded thread-stress tests and 3.14t to CI matrix
greateggsgreg May 9, 2026
2647ba2
Atomic GCHandle ownership and finalizer-thread shutdown guards
greateggsgreg May 10, 2026
57831d9
Make additional internal registries thread-safe
greateggsgreg May 10, 2026
a1b864b
test_thread: join worker threads before returning
greateggsgreg May 10, 2026
e6fa29f
test_thread: cover ModuleObject thread-safe registries
greateggsgreg May 10, 2026
eb52f66
Wider thread-safety audit fixes for free-threaded Python
greateggsgreg May 10, 2026
4377f12
Document lock acquisition sites and strong->weak GCHandle swap
greateggsgreg May 10, 2026
65681fc
Preserve InternString single-write invariant under DEBUG
greateggsgreg May 10, 2026
d8326cf
test_thread: cover real-world consumer patterns
greateggsgreg May 10, 2026
56e2ace
Auto-detect free-threaded libpython in venv home
greateggsgreg May 12, 2026
6e9f456
Snapshot pypath, use ConcurrentDictionary for thunks and slot holders
greateggsgreg May 13, 2026
2db2c1d
Fix handling of python runtime suffixes m/t
greateggsgreg May 13, 2026
c16e6bc
Fix threadtest race
greateggsgreg May 13, 2026
565150d
Fix double-free in chained ClassDerived Finalize
greateggsgreg May 13, 2026
b92751f
Enable Mono CI jobs on free-threaded Python 3.14
greateggsgreg May 13, 2026
03ff59c
Inline freethreaded_only as pytest.mark.skipif at call sites
greateggsgreg May 13, 2026
d9c6864
Fix InterruptTest assertion on free-threaded Python 3.14
greateggsgreg May 13, 2026
8795938
Merge branch 'pythonnet:master' into freethreading-prep
greateggsgreg May 14, 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
13 changes: 12 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
instance: macos-15
suffix: -macos-aarch64-none

python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]

exclude:
# Fails with initfs_encoding error
Expand All @@ -67,6 +67,17 @@ jobs:
platform: x86
python: '3.13'

# Free-threaded Python on Windows is not yet supported by pythonnet's
# native build chain; restrict 3.14t to Linux and macOS for now.
- os:
category: windows
platform: x86
python: '3.14t'
- os:
category: windows
platform: x64
python: '3.14t'

env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
Expand Down
12 changes: 11 additions & 1 deletion src/embed_tests/TestInterrupt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,17 @@ import time
Assert.That(asyncCall.Wait(TimeSpan.FromSeconds(5)), Is.True, "Async thread was not interrupted in time");
PythonEngine.EndAllowThreads(threadState);

Assert.That(asyncCall.Result, Is.EqualTo(0));
// On free-threaded CPython 3.14, PyRun_SimpleString may return -1 even
// when the script catches the async-injected KeyboardInterrupt — the
// C-level error indicator depends on which bytecode boundary the
// async-exc fires at, and isn't always cleared the way GIL builds clear
// it. The interrupt firing and the script terminating cleanly are what
// this test exercises; the return code is a side-effect that's only
// deterministic under the GIL.
if (Python.Runtime.Native.ABI.IsFreeThreaded)
Assert.That(asyncCall.Result, Is.AnyOf(0, -1));
else
Assert.That(asyncCall.Result, Is.EqualTo(0));
}
}
}
5 changes: 3 additions & 2 deletions src/embed_tests/TestNativeTypeOffset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ public class TestNativeTypeOffset
public void LoadNativeTypeOffsetClass()
{
PyObject sys = Py.Import("sys");
// We can safely ignore the "m" abi flag
// "m" is benign; "t" (free-threaded) is handled via ABI.ObjectHeadOffset
// rather than the install-time-generated NativeTypeOffset class.
var abiflags = sys.HasAttr("abiflags") ? sys.GetAttr("abiflags").ToString() : "";
abiflags = abiflags.Replace("m", "");
abiflags = abiflags.Replace("m", "").Replace("t", "");
if (!string.IsNullOrEmpty(abiflags))
{
string typeName = "Python.Runtime.NativeTypeOffset, Python.Runtime";
Expand Down
22 changes: 13 additions & 9 deletions src/runtime/AssemblyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ internal class AssemblyManager

// modified from event handlers below, potentially triggered from different .NET threads
private static readonly ConcurrentQueue<Assembly> assemblies = new();
internal static readonly List<string> pypath = new (capacity: 16);
// Snapshot; UpdatePath swaps under lock, readers iterate the captured ref.
internal static volatile IReadOnlyList<string> pypath = Array.Empty<string>();
private static readonly object _pypathLock = new();
private AssemblyManager()
{
}
Expand All @@ -49,7 +51,7 @@ private AssemblyManager()
/// </summary>
internal static void Initialize()
{
pypath.Clear();
pypath = Array.Empty<string>();

AppDomain domain = AppDomain.CurrentDomain;

Expand Down Expand Up @@ -154,19 +156,20 @@ internal static void UpdatePath()
{
BorrowedReference list = Runtime.PySys_GetObject("path");
var count = Runtime.PyList_Size(list);
if (count != pypath.Count)
if (count == pypath.Count) return;

lock (_pypathLock)
{
pypath.Clear();
if (count == pypath.Count) return;
var fresh = new List<string>(checked((int)count));
probed.Clear();
for (var i = 0; i < count; i++)
{
BorrowedReference item = Runtime.PyList_GetItem(list, i);
string? path = Runtime.GetManagedString(item);
if (path != null)
{
pypath.Add(path);
}
if (path != null) fresh.Add(path);
}
pypath = fresh;
}
}

Expand Down Expand Up @@ -196,7 +199,8 @@ public static string FindAssembly(string name)

static IEnumerable<string> FindAssemblyCandidates(string name)
{
foreach (string head in pypath)
var paths = pypath;
foreach (string head in paths)
{
string path;
if (head == null || head.Length == 0)
Expand Down
12 changes: 9 additions & 3 deletions src/runtime/ClassManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
Expand Down Expand Up @@ -33,7 +34,12 @@ internal class ClassManager
BindingFlags.Public |
BindingFlags.NonPublic;

internal static Dictionary<MaybeType, ReflectedClrType> cache = new(capacity: 128);
// cache: fully-initialised types (lock-free reads).
// _inProgressCache: partial types visible only to the lock-holding builder
// for self-referential definitions.
internal static ConcurrentDictionary<MaybeType, ReflectedClrType> cache = new();
internal static readonly ConcurrentDictionary<MaybeType, ReflectedClrType> _inProgressCache = new();
internal static readonly object _cacheCreateLock = new();
private static readonly Type dtype;

private ClassManager()
Expand Down Expand Up @@ -103,13 +109,13 @@ internal static ClassManagerState SaveRuntimeData()
return new()
{
Contexts = contexts,
Cache = cache,
Cache = new Dictionary<MaybeType, ReflectedClrType>(cache),
};
}

internal static void RestoreRuntimeData(ClassManagerState storage)
{
cache = storage.Cache;
cache = new ConcurrentDictionary<MaybeType, ReflectedClrType>(storage.Cache);
var invalidClasses = new List<KeyValuePair<MaybeType, ReflectedClrType>>();
var contexts = storage.Contexts;
foreach (var pair in cache)
Expand Down
16 changes: 7 additions & 9 deletions src/runtime/DelegateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ namespace Python.Runtime
internal class DelegateManager
{
private readonly Dictionary<Type,Type> cache = new();
// Reflection.Emit is not thread-safe; serialise cache lookup + DefineType.
private readonly object _emitLock = new();
private readonly Type basetype = typeof(Dispatcher);
private readonly Type arrayType = typeof(object[]);
private readonly Type voidtype = typeof(void);
Expand All @@ -37,18 +39,14 @@ public DelegateManager()
/// </summary>
private Type GetDispatcher(Type dtype)
{
// If a dispatcher type for the given delegate type has already
// been generated, get it from the cache. The cache maps delegate
// types to generated dispatcher types. A possible optimization
// for the future would be to generate dispatcher types based on
// unique signatures rather than delegate types, since multiple
// delegate types with the same sig could use the same dispatcher.

if (cache.TryGetValue(dtype, out Type item))
lock (_emitLock)
{
return item;
return cache.TryGetValue(dtype, out Type item) ? item : BuildDispatcher(dtype);
}
}

private Type BuildDispatcher(Type dtype)
{
string name = $"__{dtype.FullName}Dispatcher";
name = name.Replace('.', '_');
name = name.Replace('+', '_');
Expand Down
17 changes: 12 additions & 5 deletions src/runtime/Finalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public ErrorArgs(Exception error)
[DefaultValue(DefaultThreshold)]
public int Threshold { get; set; } = DefaultThreshold;

bool started;
// volatile: ThrottledCollect on PyObject ctor races with Initialize.
volatile bool started;

[DefaultValue(true)]
public bool Enable { get; set; } = true;
Expand Down Expand Up @@ -113,9 +114,11 @@ internal void ThrottledCollect()
{
if (!started) throw new InvalidOperationException($"{nameof(PythonEngine)} is not initialized");

_throttled = unchecked(this._throttled + 1);
if (!started || !Enable || _throttled < Threshold) return;
_throttled = 0;
if (!Enable || Interlocked.Increment(ref _throttled) < Threshold) return;
// Defends against externally-driven Py_Finalize (e.g. host's atexit):
// queue pointers may already be freed, so skip the drain.
if (Runtime._Py_IsFinalizing() == true) return;
Interlocked.Exchange(ref _throttled, 0);
this.Collect();
}

Expand All @@ -136,7 +139,11 @@ internal void AddFinalizedObject(ref IntPtr obj, int run
return;
}

Debug.Assert(Runtime.Refcount(new BorrowedReference(obj)) > 0);
// Skip on FT: the split refcount can race here and trip the assert spuriously.
if (!Native.ABI.IsFreeThreaded)
{
Debug.Assert(Runtime.Refcount(new BorrowedReference(obj)) > 0);
}

#if FINALIZER_CHECK
lock (_queueLock)
Expand Down
12 changes: 8 additions & 4 deletions src/runtime/InternString.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
Expand All @@ -8,8 +9,8 @@ namespace Python.Runtime
{
static partial class InternString
{
private static readonly Dictionary<string, PyString> _string2interns = new();
private static readonly Dictionary<IntPtr, string> _intern2strings = new();
private static readonly ConcurrentDictionary<string, PyString> _string2interns = new();
private static readonly ConcurrentDictionary<IntPtr, string> _intern2strings = new();
const BindingFlags PyIdentifierFieldFlags = BindingFlags.Static | BindingFlags.NonPublic;

static InternString()
Expand Down Expand Up @@ -75,8 +76,11 @@ public static bool TryGetInterned(BorrowedReference op, out string s)

private static void SetIntern(string s, PyString op)
{
_string2interns.Add(s, op);
_intern2strings.Add(op.Reference.DangerousGetAddress(), s);
// Initialize is single-threaded; TryAdd preserves the original
// single-write invariant via Debug.Assert without crashing release.
bool a = _string2interns.TryAdd(s, op);
bool b = _intern2strings.TryAdd(op.Reference.DangerousGetAddress(), s);
Debug.Assert(a && b);
}
}
}
10 changes: 7 additions & 3 deletions src/runtime/Interop.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
Expand Down Expand Up @@ -104,7 +105,8 @@ public enum TypeFlags: long

internal class Interop
{
static readonly Dictionary<MethodInfo, Type> delegateTypes = new();
// Concurrent: type-slot installation can race past TryGetValue.
static readonly ConcurrentDictionary<MethodInfo, Type> delegateTypes = new();

internal static Type GetPrototype(MethodInfo method)
{
Expand All @@ -131,15 +133,17 @@ internal static Type GetPrototype(MethodInfo method)

if (invoke.ReturnType != method.ReturnType) continue;

delegateTypes.Add(method, candidate);
delegateTypes.TryAdd(method, candidate);
return candidate;
}

throw new NotImplementedException(method.ToString());
}


internal static Dictionary<IntPtr, Delegate> allocatedThunks = new();
// Concurrent: documents the multi-writer contract previously enforced
// by callers happening to hold TypeManager._cacheCreateLock.
internal static ConcurrentDictionary<IntPtr, Delegate> allocatedThunks = new();

internal static ThunkInfo GetThunk(MethodInfo method)
{
Expand Down
28 changes: 25 additions & 3 deletions src/runtime/Native/ABI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@ namespace Python.Runtime.Native

static class ABI
{
public static int RefCountOffset { get; } = GetRefCountOffset();
public static int ObjectHeadOffset => RefCountOffset;
// GIL builds only. FT splits the refcount; Refcount uses Py_REFCNT.
public static int RefCountOffset { get; private set; }

// Added to generated TypeOffsets. FT PyObject_HEAD is 16 bytes larger.
public static int ObjectHeadOffset { get; private set; }

public static bool IsFreeThreaded { get; private set; }

internal static void Initialize(Version version)
{
IsFreeThreaded = DetectFreeThreaded();
RefCountOffset = IsFreeThreaded ? -1 : ProbeRefCountOffset();
ObjectHeadOffset = IsFreeThreaded ? 16 : RefCountOffset;

string offsetsClassSuffix = string.Format(CultureInfo.InvariantCulture,
"{0}{1}", version.Major, version.Minor);

Expand All @@ -34,7 +43,20 @@ internal static void Initialize(Version version)
TypeOffset.Use(typeOffsets, nativeOffsetsClass == null ? ObjectHeadOffset : 0);
}

static unsafe int GetRefCountOffset()
static bool DetectFreeThreaded()
{
// sys._is_gil_enabled() was added in Python 3.13; absent means GIL build.
using var sys = Runtime.PyImport_ImportModule("sys");
if (sys.IsNull()) { Runtime.PyErr_Clear(); return false; }
using var func = Runtime.PyObject_GetAttrString(sys.Borrow(), "_is_gil_enabled");
if (func.IsNull()) { Runtime.PyErr_Clear(); return false; }
using var args = Runtime.PyTuple_New(0);
using var result = Runtime.PyObject_Call(func.Borrow(), args.Borrow(), default);
if (result.IsNull()) { Runtime.PyErr_Clear(); return false; }
return Runtime.PyObject_IsTrue(result.Borrow()) == 0;
}

static unsafe int ProbeRefCountOffset()
{
using var tempObject = Runtime.PyList_New(0);
IntPtr* tempPtr = (IntPtr*)tempObject.DangerousGetAddress();
Expand Down
Loading
Loading