Skip to content

Commit 460abce

Browse files
Martin-Molinerogreateggsgregfilmor
authored
Fix MethodBinding/OverloadMapper memory leak (pythonnet#691) (pythonnet#2719) (#117)
* Fix MethodBinding/OverloadMapper memory leak (pythonnet#691) MethodBinding and OverloadMapper held PyObject `target` references that were not disposed during tp_clear, leaving Python-side refcount drops to wait on the multi-hop .NET finalizer chain. They also shared the same C# PyObject instance across mp_subscript/Overloads paths, so freeing one could free the underlying Python object out from under the others. - ExtensionType: add virtual OnClear() hook called from tp_clear before the GCHandle is released, letting subclasses eagerly drop owned Python references. - MethodBinding/OverloadMapper: override OnClear to dispose `target`. (`targetType` is intentionally not disposed since Python types are long-lived and tracked by other caches.) - Take an independent INCREF'd PyObject copy at every site that hands a shared target into a new MethodBinding or OverloadMapper, so each wrapper owns its own reference. Result: the three _does_not_leak_memory tests drop from ~485 MB delta to ~10 KB delta on Python 3.14. * Tighten leak-test threshold to 10% to actually fail the bug The previous 90% threshold (0.9 MB/iter against a 1 MB allocation) documented the issue but did not reproduce it: master leaks ~600-765 KB/iter, which the 0.9 MB threshold accepts as passing. Drop the threshold to 10% (104 KB/iter). On the 2026-05-09 verification run with Python 3.14 GIL on linux-aarch64: Without fix (master): ~572-765 KB/iter (FAIL) With fix (this branch): ~-500 B/iter (PASS) Margin is roughly 6x in either direction across .NET 8 and .NET 10, so the threshold cleanly separates buggy from fixed states without being sensitive to GC noise. * Bugfix and improvements - Handle the `PyType` reference in `OverloadMapper` and `MethodBinding` in the same way as the object reference - Unconditionally store the `PyType` of the object - Introduce `NewReference` helper function for the object and type passing - Fix the remaining missing reference count bump for the type (`MethodObject`) - As the count is now correct, `Dispose` the type as well --------- (cherry picked from commit ca323cc) Co-authored-by: greateggsgreg <36009512+greateggsgreg@users.noreply.github.com> Co-authored-by: Benedikt Reinartz <filmor@gmail.com>
1 parent 022fc98 commit 460abce

7 files changed

Lines changed: 56 additions & 19 deletions

File tree

src/runtime/PythonTypes/PyObject.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ internal PyObject(in StolenReference reference)
9393
Finalizer.Instance.ThrottledCollect();
9494
}
9595

96+
/// <summary>
97+
/// Create a new PyObject instance of this object, bumping the reference
98+
/// count.
99+
/// </summary>
100+
public PyObject NewReference() => new(this);
101+
96102
// Ensure that encapsulated Python object is decref'ed appropriately
97103
// when the managed wrapper is garbage-collected.
98104
~PyObject()

src/runtime/PythonTypes/PyType.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ internal PyType(in StolenReference reference, bool prevalidated = false) : base(
3535
throw new ArgumentException("object is not a type");
3636
}
3737

38+
/// <summary>
39+
/// Create a new PyType instance of this object, bumping the reference
40+
/// count.
41+
/// </summary>
42+
public new PyType NewReference() => new(this);
43+
3844
protected PyType(SerializationInfo info, StreamingContext context) : base(info, context) { }
3945

4046
internal new static PyType? FromNullableReference(BorrowedReference reference)

src/runtime/Types/ExtensionType.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,18 @@ public unsafe static void tp_dealloc(NewReference lastRef)
7979
DecrefTypeAndFree(lastRef.Steal());
8080
}
8181

82+
/// <summary>
83+
/// Called during tp_clear before the GCHandle is released.
84+
/// Override to eagerly dispose Python object references (PyObject fields)
85+
/// held by the subclass, preventing the multi-hop .NET finalizer chain
86+
/// from delaying Python-side refcount decrements.
87+
/// </summary>
88+
protected virtual void OnClear() { }
89+
8290
public static int tp_clear(BorrowedReference ob)
8391
{
92+
(GetManagedObject(ob) as ExtensionType)?.OnClear();
93+
8494
var weakrefs = Runtime.PyObject_GetWeakRefList(ob);
8595
if (weakrefs != null)
8696
{

src/runtime/Types/MethodBinding.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,12 @@ internal class MethodBinding : ExtensionType
2020
internal MaybeMethodInfo info;
2121
internal MethodObject m;
2222
internal PyObject? target;
23-
internal PyType? targetType;
23+
internal PyType targetType;
2424

25-
public MethodBinding(MethodObject m, PyObject? target, PyType? targetType = null)
25+
public MethodBinding(MethodObject m, PyObject? target, PyType targetType)
2626
{
2727
this.target = target;
28-
29-
this.targetType = targetType ?? target?.GetPythonType();
30-
28+
this.targetType = targetType;
3129
this.info = null;
3230
this.m = m;
3331
}
@@ -64,7 +62,7 @@ public static NewReference mp_subscript(BorrowedReference tp, BorrowedReference
6462
}
6563

6664
MethodObject overloaded = self.m.WithOverloads(overloads);
67-
var mb = new MethodBinding(overloaded, self.target, self.targetType);
65+
var mb = new MethodBinding(overloaded, self.target?.NewReference(), self.targetType.NewReference());
6866
return mb.Alloc();
6967
}
7068

@@ -151,7 +149,7 @@ public static NewReference tp_getattro(BorrowedReference ob, BorrowedReference k
151149
// FIXME: deprecate __overloads__ soon...
152150
case "__overloads__":
153151
case "Overloads":
154-
var om = new OverloadMapper(self.m, self.target);
152+
var om = new OverloadMapper(self.m, self.target?.NewReference(), self.targetType.NewReference());
155153
return om.Alloc();
156154
case "__signature__" when Runtime.InspectModule is not null:
157155
var sig = self.Signature;
@@ -261,7 +259,6 @@ public static NewReference tp_call(BorrowedReference ob, BorrowedReference args,
261259
}
262260
}
263261

264-
265262
/// <summary>
266263
/// MethodBinding __hash__ implementation.
267264
/// </summary>
@@ -293,5 +290,12 @@ public static NewReference tp_repr(BorrowedReference ob)
293290
string name = self.m.name;
294291
return Runtime.PyString_FromString($"<{type} method '{name}'>");
295292
}
293+
294+
protected override void OnClear()
295+
{
296+
target?.Dispose();
297+
targetType.Dispose();
298+
target = null;
299+
}
296300
}
297301
}

src/runtime/Types/MethodObject.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,8 @@ public static NewReference tp_descr_get(BorrowedReference ds, BorrowedReference
226226
&& obj.inst is IPythonDerivedType
227227
&& self.type.Value.IsInstanceOfType(obj.inst))
228228
{
229-
var basecls = ClassManager.GetClass(self.type.Value);
230-
return new MethodBinding(self, new PyObject(ob), basecls).Alloc();
229+
var basecls = ReflectedClrType.GetOrCreate(self.type.Value);
230+
return new MethodBinding(self, new PyObject(ob), basecls.NewReference()).Alloc();
231231
}
232232

233233
return new MethodBinding(self, target: new PyObject(ob), targetType: new PyType(tp)).Alloc();

src/runtime/Types/OverloadMapper.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ namespace Python.Runtime
99
/// </summary>
1010
internal class OverloadMapper : ExtensionType
1111
{
12-
private MethodObject m;
12+
private readonly MethodObject m;
1313
private PyObject? target;
14+
readonly PyType targetType;
1415

15-
public OverloadMapper(MethodObject m, PyObject? target)
16+
public OverloadMapper(MethodObject m, PyObject? target, PyType targetType)
1617
{
1718
this.target = target;
19+
this.targetType = targetType;
1820
this.m = m;
1921
}
2022

@@ -42,7 +44,7 @@ public static NewReference mp_subscript(BorrowedReference tp, BorrowedReference
4244
return Exceptions.RaiseTypeError(e);
4345
}
4446

45-
var mb = new MethodBinding(self.m, self.target) { info = mi };
47+
var mb = new MethodBinding(self.m, self.target?.NewReference(), self.targetType.NewReference()) { info = mi };
4648
return mb.Alloc();
4749
}
4850

@@ -54,5 +56,12 @@ public static NewReference tp_repr(BorrowedReference op)
5456
var self = (OverloadMapper)GetManagedObject(op)!;
5557
return self.m.GetDocString();
5658
}
59+
60+
protected override void OnClear()
61+
{
62+
target?.Dispose();
63+
targetType.Dispose();
64+
target = null;
65+
}
5766
}
5867
}

tests/test_method.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -961,8 +961,10 @@ def test_getting_generic_method_binding_does_not_leak_memory():
961961
bytesAllocatedPerIteration = pow(2, 20) # 1MB
962962
bytesLeakedPerIteration = processBytesDelta / iterations
963963

964-
# Allow 50% threshold - this shows the original issue is fixed, which leaks the full allocated bytes per iteration
965-
failThresholdBytesLeakedPerIteration = bytesAllocatedPerIteration / 2
964+
# Tight 10% threshold: with the fix the per-iteration leak is essentially
965+
# zero, while the bug retains the bulk of the 1 MB payload (~600 KB/iter
966+
# on 3.14 GIL). 100 KB/iter cleanly distinguishes the two states.
967+
failThresholdBytesLeakedPerIteration = bytesAllocatedPerIteration * 0.1
966968

967969
assert bytesLeakedPerIteration < failThresholdBytesLeakedPerIteration
968970

@@ -1005,8 +1007,8 @@ def test_getting_overloaded_method_binding_does_not_leak_memory():
10051007
bytesAllocatedPerIteration = pow(2, 20) # 1MB
10061008
bytesLeakedPerIteration = processBytesDelta / iterations
10071009

1008-
# Allow 50% threshold - this shows the original issue is fixed, which leaks the full allocated bytes per iteration
1009-
failThresholdBytesLeakedPerIteration = bytesAllocatedPerIteration / 2
1010+
# Tight 10% threshold; see test_getting_generic_method_binding_does_not_leak_memory.
1011+
failThresholdBytesLeakedPerIteration = bytesAllocatedPerIteration * 0.1
10101012

10111013
assert bytesLeakedPerIteration < failThresholdBytesLeakedPerIteration
10121014

@@ -1049,8 +1051,8 @@ def test_getting_method_overloads_binding_does_not_leak_memory():
10491051
bytesAllocatedPerIteration = pow(2, 20) # 1MB
10501052
bytesLeakedPerIteration = processBytesDelta / iterations
10511053

1052-
# Allow 50% threshold - this shows the original issue is fixed, which leaks the full allocated bytes per iteration
1053-
failThresholdBytesLeakedPerIteration = bytesAllocatedPerIteration / 2
1054+
# Tight 10% threshold; see test_getting_generic_method_binding_does_not_leak_memory.
1055+
failThresholdBytesLeakedPerIteration = bytesAllocatedPerIteration * 0.1
10541056

10551057
assert bytesLeakedPerIteration < failThresholdBytesLeakedPerIteration
10561058

0 commit comments

Comments
 (0)