| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| """Tests for the PyObject-tying feature (cache & revive). |
| |
| A 16-byte ``PyCustomAllocHeader`` is prepended to every Object that goes |
| through the Python custom allocator. Its ``tagged_pyobj`` field is a tagged |
| pointer to the canonical Python wrapper, encoding a three-state machine |
| driven by the custom ``tp_alloc`` / ``tp_free`` slots and the |
| ``make_ret_object`` dispatcher in ``tvm_ffi_python_object.h``: |
| |
| Detached no Python wrapper bound to the chandle (raw == NULL) |
| Active canonical wrapper alive; ``wrapper.chandle == chandle`` (tag 0) |
| Inactive wrapper dropped but its memory is cached as a dead, |
| untracked allocation (tag 1); revived in place at the same address |
| on the next ``make_ret_object`` |
| |
| Active gives ``a.x is a.x``. Inactive gives stable ``id()`` across a |
| drop-then-rewrap cycle when the C++ object outlives the wrapper — without a |
| leak: the cached allocation is revived (``tp_alloc``), not resurrected. |
| """ |
| |
| from __future__ import annotations |
| |
| import gc |
| import itertools |
| import pickle |
| import sys |
| import threading |
| import weakref |
| from typing import Any |
| |
| import pytest |
| import tvm_ffi |
| import tvm_ffi.testing |
| from tvm_ffi import dataclasses as dc |
| |
| pytestmark = pytest.mark.skip( |
| reason="PyObject tying is compiled but dormant until allocator activation" |
| ) |
| |
| # --------------------------------------------------------------------------- |
| # Test type registration |
| # --------------------------------------------------------------------------- |
| _counter = itertools.count() |
| |
| |
| def _unique_key(base: str) -> str: |
| return f"testing.pyobject_tying.{base}_{next(_counter)}" |
| |
| |
| def _is_free_threaded_python() -> bool: |
| return hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled() |
| |
| |
| # PyObject-tying (wrapper identity stickiness: ``a.x is a.x``, stable ``id()`` across |
| # drop+revive, ``f(x) is x``) relies on a custom allocator + tp_alloc / tp_free / |
| # tp_dealloc slots whose header state machine is GIL-serialized on the classic build |
| # and lock-synchronized on free-threaded builds (see the "Free-threaded builds" note |
| # in ``tvm_ffi_python_object.h``). The feature is enabled on both, so the identity |
| # tests below run unconditionally on every build. |
| |
| |
| # Module-level fixtures so the registered types persist across all tests |
| # in a file (re-registering with the same key is an error). |
| |
| |
| @dc.py_class(_unique_key("Inner")) |
| class Inner(dc.Object): |
| val: int |
| |
| |
| @dc.py_class(_unique_key("Outer")) |
| class Outer(dc.Object): |
| x: Inner |
| |
| |
| @dc.py_class(_unique_key("MutableOuter"), frozen=False) |
| class MutableOuter(dc.Object): |
| x: Inner |
| |
| |
| # --------------------------------------------------------------------------- |
| # Active: identity stable while wrapper is alive |
| # --------------------------------------------------------------------------- |
| class TestActiveIdentity: |
| """``a.x is a.x`` and ``id(a.x)`` stable while ``a`` lives.""" |
| |
| def test_attr_access_is_same_object(self) -> None: |
| """Repeated (and chained) attribute access returns the same wrapper object.""" |
| a = Outer(Inner(42)) |
| assert a.x is a.x |
| # id() is stable across many accesses, and chained access reuses the |
| # intermediate wrapper rather than minting transient garbage. |
| assert len({id(a.x) for _ in range(100)}) == 1 |
| assert a.x.val == 42 |
| |
| def test_two_outers_distinct_inners(self) -> None: |
| """Distinct C++ objects produce distinct Python wrappers.""" |
| a = Outer(Inner(1)) |
| b = Outer(Inner(2)) |
| assert a.x is not b.x |
| assert id(a.x) != id(b.x) |
| |
| |
| # --------------------------------------------------------------------------- |
| # Universal cache-on: function returns alias the canonical wrapper |
| # --------------------------------------------------------------------------- |
| class TestFunctionReturns: |
| """Every FFI return funnels through ``make_ret_object``: the wrapper |
| for a chandle that already has a canonical Python wrapper *is* that |
| wrapper. |
| |
| Note: ``get_global_func("name")`` does NOT participate in this cache |
| (most registry entries are allocated at C++ static init, before the |
| Python custom allocator is registered, so their chandle has no |
| PyCustomAllocHeader). Stable ``id()`` for ``get_global_func`` is |
| deferred — see TODO in ``function.pxi::_get_global_func``. |
| """ |
| |
| def test_function_return_aliases_arg(self) -> None: |
| """An FFI return aliases the canonical wrapper of its argument.""" |
| identity = tvm_ffi.convert(lambda x: x) |
| x = tvm_ffi.convert([1, 2]) |
| assert identity(x) is x |
| |
| |
| # --------------------------------------------------------------------------- |
| # Active -> Inactive -> Active: identity preserved across wrapper drop |
| # --------------------------------------------------------------------------- |
| class TestRevive: |
| """When the C++ object outlives the wrapper, re-fetching it via the |
| cached field-getter path reuses the preserved memory (same address, |
| same chandle, correct value). |
| """ |
| |
| def test_id_chandle_and_value_preserved_across_revive(self) -> None: |
| """id(), chandle, and field value all survive a drop-and-revive cycle.""" |
| outer = Outer(Inner(123)) |
| first = outer.x # Active: canonical wrapper installed |
| first_id = id(first) |
| first_chandle = first.__chandle__() |
| del first |
| gc.collect() # transitions to Inactive |
| |
| revived = outer.x # revive at the same address |
| assert id(revived) == first_id |
| assert revived.__chandle__() == first_chandle |
| assert revived.val == 123 |
| |
| def test_no_leak_churn_2k_cycles(self) -> None: |
| """Revive must reuse exactly one address across many cycles.""" |
| outer = Outer(Inner(1)) |
| addresses: set[int] = set() |
| for i in range(2000): |
| ref = outer.x |
| addresses.add(id(ref)) |
| del ref |
| if i % 500 == 499: |
| gc.collect() |
| gc.collect() |
| assert len(addresses) == 1, ( |
| f"Inactive -> Active revive should yield exactly one wrapper address; " |
| f"saw {len(addresses)}" |
| ) |
| |
| def test_chandle_dies_while_inactive(self) -> None: |
| """A chandle dying while Inactive reclaims the cached allocation safely.""" |
| # When the chandle finally dies while its wrapper is Inactive, |
| # ``TVMFFIPyDeleteSpace`` must reclaim the cached (untracked) |
| # allocation under the GIL via ``PyObject_GC_Del`` and then free the |
| # malloc block. A regression here typically manifests as a |
| # segfault — getting past ``gc.collect()`` and the follow-up |
| # alloc means the path stayed safe. |
| # |
| # A UAF/corruption here is deterministic -- it trips within the first |
| # few iterations (the alloc-after-free that surfaces heap damage needs |
| # only a handful of repeats), so a small count suffices. Each iter runs |
| # two gc.collect()s (~13ms each in this env), so keep the count modest. |
| for _ in range(8): |
| outer = Outer(Inner(7)) |
| _ = outer.x # Detached -> Active |
| gc.collect() # wrapper drops; Active -> Inactive |
| del outer # drops the last C++ ref; deleter fires on the inactive cached allocation |
| gc.collect() |
| # If we got here, the cached-allocation cleanup path is safe. |
| fresh = Outer(Inner(11)) |
| assert fresh.x.val == 11 |
| |
| |
| # --------------------------------------------------------------------------- |
| # No unbounded wrapper leak across many distinct chandles |
| # --------------------------------------------------------------------------- |
| class TestNoUnboundedLeak: |
| """Many distinct chandles, each pinned alive by a parent's field, must |
| not accumulate one live Python wrapper per chandle. |
| |
| ``TestRevive.test_no_leak_churn_2k_cycles`` cycles a *single* chandle and |
| asserts address reuse; this asserts the complementary property -- across |
| *many distinct* chandles the count of live wrappers stays flat, because |
| cache & revive reuses the cached allocation instead of pinning it (rather |
| than keeping one live wrapper alive per chandle for as long as the chandle |
| survives). |
| """ |
| |
| def test_distinct_chandles_no_wrapper_leak(self) -> None: |
| """Many distinct chandles do not pin one live wrapper each.""" |
| |
| # Locally-registered types: the gc.get_objects() scan below counts |
| # only instances of *this* InnerLeak, so leftover instances from other |
| # tests (which use the module-level Inner) cannot pollute the count. |
| @dc.py_class(_unique_key("InnerLeak")) |
| class InnerLeak(dc.Object): |
| val: int |
| |
| @dc.py_class(_unique_key("OuterLeak")) |
| class OuterLeak(dc.Object): |
| x: InnerLeak |
| |
| def live_inner_count() -> int: |
| return sum(1 for ob in gc.get_objects() if type(ob) is InnerLeak) |
| |
| # n is deliberately modest: a leak here would be strictly linear (one |
| # pinned wrapper per distinct chandle), so leaked == 0 trips at any n |
| # under a regression -- a larger n buys no detection power, only time. |
| n = 1000 |
| parents = [] |
| gc.collect() |
| before = live_inner_count() |
| for i in range(n): |
| outer = OuterLeak(InnerLeak(i)) |
| parents.append(outer) # keep the chandle legitimately alive |
| w = outer.x # Active wrapper for this distinct chandle |
| del w # -> Inactive (revivable cached allocation, must not pin the wrapper) |
| gc.collect() |
| leaked = live_inner_count() - before |
| assert leaked == 0, ( |
| f"PyObject-tying leak: {leaked} wrappers pinned alive (expected 0; " |
| f"one pinned wrapper per distinct chandle == unbounded leak)" |
| ) |
| |
| |
| # --------------------------------------------------------------------------- |
| # Finalizer (__del__) types are excluded from inactivation |
| # --------------------------------------------------------------------------- |
| class TestFinalizerNotInactivated: |
| """A subclass that defines ``__del__`` gets a ``tp_finalize`` slot, and |
| CPython sets a *permanent* GC-finalized bit the first time it runs — |
| never running it again on that block. Reviving an inactive cached allocation would |
| therefore silently suppress ``__del__`` for every revived generation. |
| ``TVMFFIPyIsInactiveEligible`` excludes such types, so they genuinely free on |
| every drop and ``__del__`` fires once per generation. The cost is no |
| stable-``id``-across-drop for finalizer types (acceptable, documented). |
| """ |
| |
| def test_del_fires_every_generation(self) -> None: |
| """__del__ fires on every drop of a finalizer type, not once-ever.""" |
| calls: list[int] = [] |
| |
| @dc.py_class(_unique_key("InnerDel")) |
| class InnerDel(dc.Object): |
| val: int |
| |
| def __del__(self) -> None: |
| # ``self`` is being finalized; record that __del__ ran. |
| calls.append(1) |
| |
| @dc.py_class(_unique_key("OuterDel")) |
| class OuterDel(dc.Object): |
| x: InnerDel |
| |
| outer = OuterDel(InnerDel(5)) |
| for _ in range(5): |
| w = outer.x # fresh wrapper (finalizer type is never inactivated) |
| assert w.val == 5 |
| del w |
| gc.collect() # genuine free -> __del__ runs |
| # __del__ fired once per drop (5), not once-ever (the cached-allocation bug). |
| assert len(calls) >= 5, ( |
| f"__del__ must fire on every drop of a finalizer type; saw {len(calls)}" |
| ) |
| |
| def test_finalizer_type_value_correct_across_refetch(self) -> None: |
| """A finalizer type's value and live identity hold across refetch.""" |
| |
| # Even without stable id(), the value and live identity must hold. |
| @dc.py_class(_unique_key("InnerDel")) |
| class InnerDel2(dc.Object): |
| val: int |
| |
| def __del__(self) -> None: |
| pass |
| |
| @dc.py_class(_unique_key("OuterDel")) |
| class OuterDel2(dc.Object): |
| x: InnerDel2 |
| |
| outer = OuterDel2(InnerDel2(7)) |
| assert outer.x is outer.x # live identity still holds |
| assert outer.x.val == 7 |
| gc.collect() |
| assert outer.x.val == 7 # refetch after drop still correct |
| |
| |
| # --------------------------------------------------------------------------- |
| # Inactivation only happens when other C++ holders exist |
| # --------------------------------------------------------------------------- |
| class TestLastRefCleanFree: |
| """When the wrapper is the last C++ ref, ``__dealloc__`` must NOT |
| inactivate — it detaches and genuinely frees the allocation (``tp_free`` runs |
| the real free). Regression guard for use-after-free in the deleter. |
| """ |
| |
| def test_drop_last_ref_no_crash(self) -> None: |
| """The bare convert-and-drop pattern must not segfault.""" |
| for _ in range(100): |
| x = tvm_ffi.convert([1, 2, 3]) |
| del x |
| gc.collect() |
| |
| def test_drop_then_realloc_value_correct(self) -> None: |
| """Dropping the last ref frees the memory; a fresh alloc is correct.""" |
| # Inner has no other holder -> __dealloc__ with strong_count == 1 -> |
| # genuine free (no inactivation), memory reclaimed. Next Inner is fresh. |
| a = Inner(1) |
| del a |
| gc.collect() |
| b = Inner(2) |
| assert b.val == 2 |
| |
| |
| # --------------------------------------------------------------------------- |
| # RValue-ref move path (regression for the test_rvalue_ref segfault) |
| # --------------------------------------------------------------------------- |
| class TestRValueRef: |
| """The ``_move()`` path nulls the source's ``chandle`` while the |
| Python wrapper stays alive. The RValueRef arg setter detaches the |
| canonical-wrapper binding eagerly so a downstream cache lookup |
| doesn't see a stale back-pointer to a wrapper whose chandle is NULL. |
| """ |
| |
| def test_move_into_callback_no_crash(self) -> None: |
| """Moving an arg into a callback that re-moves it does not crash.""" |
| # Universal cache-on: callback arg aliases caller's ``x`` (one |
| # wrapper, one chandle ref). The original blocker was a UAF in |
| # the deleter path triggered by the eager-detach + chandle-null |
| # interplay during a callback that returns ``x._move()`` — so we |
| # exercise that path here, asserting only that we don't crash |
| # and the post-call chandle is null. |
| use_count = tvm_ffi.get_global_func("testing.object_use_count") |
| |
| def callback(x: Any, expected: int) -> Any: |
| gc.collect() |
| assert expected == use_count(x) |
| return x._move() |
| |
| f = tvm_ffi.convert(callback) |
| x = tvm_ffi.convert([1, 2]) |
| # Caller-side _move: the rvalue setter detaches and the |
| # callback receives a fresh canonical wrapper for the chandle. |
| f(x._move(), 1) |
| assert x.__ctypes_handle__().value is None |
| |
| def test_ffi_move_then_re_fetch_works(self) -> None: |
| """Re-fetching a field after an FFI move yields a valid wrapper.""" |
| # The rvalue setter only runs when an FFI call consumes the |
| # ObjectRValueRef. ``discard`` provides that consumption — the |
| # setter detaches the canonical-wrapper binding (clearing |
| # ``tagged_pyobj`` to NULL) and the C++ side nulls the |
| # source wrapper's ``chandle``. ``outer``'s field still owns |
| # its strong ref, so re-fetching produces a valid wrapper. |
| outer = Outer(Inner(5)) |
| discard = tvm_ffi.convert(lambda _: None) |
| discard(outer.x._move()) |
| assert outer.x.val == 5 |
| |
| def test_active_caching_after_ffi_move(self) -> None: |
| """Active caching resumes on a fresh wrapper after an FFI move.""" |
| # Eager-detach during the move clears the header binding, so |
| # the chandle returns to Detached. The next access installs a fresh |
| # canonical wrapper, which then participates in Active caching |
| # like any other freshly-wrapped chandle. |
| outer = Outer(Inner(5)) |
| discard = tvm_ffi.convert(lambda _: None) |
| discard(outer.x._move()) |
| assert outer.x is outer.x |
| assert outer.x.val == 5 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Pickle round-trip: must not corrupt the binding |
| # --------------------------------------------------------------------------- |
| class TestPickle: |
| """Pickle uses ``__init_handle_by_constructor__`` which calls |
| ``_install_chandle_binding`` — the binding must end in a valid |
| Active configuration. |
| """ |
| |
| def test_pickle_roundtrip_basic(self) -> None: |
| """A basic pickle round-trip preserves the value.""" |
| a = tvm_ffi.convert([1, 2, 3]) |
| s = pickle.dumps(a) |
| b = pickle.loads(s) |
| assert list(b) == [1, 2, 3] |
| |
| def test_pickle_roundtrip_preserves_attr_identity(self) -> None: |
| """A restored wrapper has stable attribute identity.""" |
| outer = Outer(Inner(77)) |
| s = pickle.dumps(outer) |
| outer2 = pickle.loads(s) |
| # Restored wrapper is fully functional and identity is stable |
| # within the new instance. |
| assert outer2.x is outer2.x |
| assert outer2.x.val == 77 |
| |
| |
| # --------------------------------------------------------------------------- |
| # PyNativeObject (String / Bytes) — exempt from the header binding |
| # --------------------------------------------------------------------------- |
| class TestPyNativeExempt: |
| """PyNativeObject types (``String``, ``Bytes``) are value-typed: the |
| transient ``Object`` wrapper is discarded after construction. |
| They MUST NOT install a header binding. |
| """ |
| |
| def test_string_construction_and_compare(self) -> None: |
| """A converted String behaves as a native str.""" |
| s = tvm_ffi.convert("hello") |
| assert isinstance(s, str) |
| assert s == "hello" |
| |
| def test_bytes_construction_and_compare(self) -> None: |
| """A converted Bytes behaves as native bytes.""" |
| b = tvm_ffi.convert(b"world") |
| assert isinstance(b, bytes) |
| assert b == b"world" |
| |
| def test_string_repeated_construction(self) -> None: |
| """Repeated String construction does not corrupt adjacent headers.""" |
| # Repeatedly constructing strings shouldn't break the header on |
| # adjacent objects. |
| for _ in range(100): |
| s = tvm_ffi.convert("x" * 16) |
| assert s == "x" * 16 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Type-mismatch fallthrough on revive |
| # --------------------------------------------------------------------------- |
| class TestTypeMismatchOnRevive: |
| """Registering many unrelated types between attribute accesses must |
| not corrupt existing wrappers' bindings, and the revive path must |
| still hit Inactive for the original wrapper. |
| """ |
| |
| def test_field_access_works_after_many_unrelated_registrations(self) -> None: |
| """Unrelated type registrations don't corrupt an existing binding.""" |
| outer = Outer(Inner(1)) |
| first_id = id(outer.x) |
| # Register a bunch of unrelated classes (no shared chandle). |
| for _ in range(20): |
| |
| @dc.py_class(_unique_key("Tmp")) |
| class Tmp(dc.Object): |
| v: int |
| |
| _ = Tmp(0) |
| gc.collect() |
| # Original outer's revive cycle still works. |
| revived = outer.x |
| assert id(revived) == first_id |
| assert revived.val == 1 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Field setter after revive |
| # --------------------------------------------------------------------------- |
| class TestFieldSetterAfterRevive: |
| """Replacing a field after a revive cycle should bind the new value |
| cleanly without disturbing the prior preserved wrapper for the |
| *previous* chandle (which may still be alive elsewhere). |
| """ |
| |
| def test_assign_then_access(self) -> None: |
| """Replacing a field after a revive cycle binds the new value cleanly.""" |
| outer = MutableOuter(Inner(1)) |
| first_inner = outer.x |
| first_chandle = first_inner.__chandle__() |
| del first_inner |
| |
| # Replace x with a fresh Inner. |
| new_inner = Inner(2) |
| outer.x = new_inner |
| gc.collect() |
| |
| # The new outer.x has a different chandle (= new_inner's), so it |
| # must produce a wrapper that is NOT the preserved address of |
| # the original (which had a different chandle). |
| revised = outer.x |
| assert revised.val == 2 |
| assert revised.__chandle__() != first_chandle |
| # The new wrapper is canonical for its own chandle. |
| assert outer.x is revised |
| |
| |
| # --------------------------------------------------------------------------- |
| # Pickle stress: repeated round-trips |
| # --------------------------------------------------------------------------- |
| class TestPickleStress: |
| """Pickle round-trips go through ``__init_handle_by_constructor__`` |
| which calls ``_install_chandle_binding``. Repeated round-trips must |
| not leak wrappers or corrupt the binding state. |
| """ |
| |
| def test_many_roundtrips(self) -> None: |
| """Repeated pickle round-trips keep the binding state valid.""" |
| for _ in range(200): |
| outer = Outer(Inner(7)) |
| restored = pickle.loads(pickle.dumps(outer)) |
| assert restored.x.val == 7 |
| assert restored.x is restored.x # Active on restored binding |
| |
| def test_roundtrip_then_revive(self) -> None: |
| """A pickle-installed binding survives a finalize-and-revive cycle.""" |
| # Round-trip, then exercise Inactive -> Active revive on the restored |
| # wrapper to confirm the binding installed by pickle survives |
| # finalize+rewrap. |
| outer = pickle.loads(pickle.dumps(Outer(Inner(99)))) |
| first_id = id(outer.x) |
| gc.collect() |
| assert id(outer.x) == first_id |
| assert outer.x.val == 99 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Threading stress: concurrent attribute access |
| # --------------------------------------------------------------------------- |
| class TestThreadingStress: |
| """Multi-threaded smoke test for the cache-hit / Inactive revive |
| paths. Under classic CPython the GIL serializes Python bytecode so |
| most races are confined to the FFI call's nogil block; this test |
| exercises enough iterations to surface obvious crashes from any |
| GIL-released DecRef on the C++ side. |
| """ |
| |
| def test_concurrent_shared_churn_no_crash(self) -> None: |
| """Many threads hammering ``outer.x`` on ONE shared object must not crash. |
| |
| This is the high-pressure free-threaded reproducer: a single ``Outer`` is |
| shared across all threads, so every thread's ``outer.x`` drop+refetch races |
| the others' through the same ``tagged_pyobj`` word. The cached ``Inner`` |
| wrapper churns Active <-> Inactive while ``make_ret`` Active-hits read it and |
| ``tp_dealloc`` mutates it. On free-threaded builds with no synchronization |
| this segfaults within ~1s; it is the exact race the lock-synchronized state |
| machine plus the pre-bump ``tp_dealloc`` binding-clear close. Also asserts the |
| live wrapper identity (``outer.x is outer.x``) holds under contention. |
| """ |
| outer = Outer(Inner(123)) |
| # More threads than cores maximizes interleaving; iteration count is sized to |
| # surface the crash reliably on FT while keeping the GIL build well under a |
| # second. Drop the count a bit on the GIL build (the race cannot occur there). |
| nthreads = 16 |
| iters = 20_000 if _is_free_threaded_python() else 2_000 |
| barrier = threading.Barrier(nthreads) |
| errors: list[BaseException] = [] |
| |
| def worker() -> None: |
| try: |
| barrier.wait() |
| for _ in range(iters): |
| a = outer.x # Detached/Inactive -> Active, or Active hit |
| b = outer.x # Active hit on the just-installed wrapper |
| assert a is b, "live wrapper identity must hold under contention" |
| assert a.val == 123 |
| del a, b |
| except BaseException as e: |
| errors.append(e) |
| |
| threads = [threading.Thread(target=worker) for _ in range(nthreads)] |
| for t in threads: |
| t.start() |
| for t in threads: |
| t.join() |
| assert not errors, f"worker(s) raised: {errors!r}" |
| |
| |
| # --------------------------------------------------------------------------- |
| # Carrier family: tying covers all CObject subtypes, not just dataclasses |
| # --------------------------------------------------------------------------- |
| class TestCarrierTypes: |
| """``tp_dealloc`` is defined once on ``CObject`` and inherited by every |
| subtype (``Function``, ``Error``, heap subclasses, ``OpaquePyObject``), so |
| tying and clean teardown must hold for the whole family, not only the |
| dataclass ``Inner`` path. Each carrier is pinned alive and re-fetched; the |
| Active-hit aliases the canonical wrapper. |
| """ |
| |
| def test_function_and_error_carrier_alias(self) -> None: |
| """Function and Error carriers re-fetched from a container alias one wrapper.""" |
| fn_box = tvm_ffi.convert([tvm_ffi.convert(lambda z: z)]) |
| assert fn_box[0] is fn_box[0] |
| err_box = tvm_ffi.convert([tvm_ffi.convert(ValueError("boom"))]) |
| assert err_box[0] is err_box[0] |
| |
| def test_multilevel_heap_subtype_alias(self) -> None: |
| """A 2-level heap dataclass subtype ties like any other CObject.""" |
| |
| # ``Derived``'s tp_dealloc is CPython's subtype_dealloc, which walks to |
| # the inherited CObject slot -- so tying still applies. |
| @dc.py_class(_unique_key("CarrierBase")) |
| class Base(dc.Object): |
| a: int |
| |
| @dc.py_class(_unique_key("CarrierDerived")) |
| class Derived(Base): |
| pass |
| |
| @dc.py_class(_unique_key("CarrierHolder")) |
| class Holder(dc.Object): |
| x: Base |
| |
| holder = Holder(Derived(9)) |
| assert holder.x is holder.x |
| assert holder.x.a == 9 |
| |
| |
| # --------------------------------------------------------------------------- |
| # OpaquePyObject carrier |
| # --------------------------------------------------------------------------- |
| class TestOpaquePyObjectCarrier: |
| """``OpaquePyObject`` carries an arbitrary (non-FFI) Python value across the |
| FFI boundary. Its round-trip must preserve identity and its teardown must |
| release the held value (the inherited ``CObject`` dealloc runs the chandle |
| DecRef), so payloads are reclaimed rather than leaked. |
| """ |
| |
| def test_opaque_roundtrip_preserves_identity(self) -> None: |
| """A Python value round-tripped through an FFI echo is the same object.""" |
| echo = tvm_ffi.convert(lambda x: x) |
| |
| class Payload: |
| __slots__ = ("v",) |
| |
| def __init__(self, v: int) -> None: |
| self.v = v |
| |
| p = Payload(7) |
| r = echo(p) |
| assert r is p |
| assert r.v == 7 |
| |
| def test_opaque_roundtrip_does_not_leak(self) -> None: |
| """Echoed payloads are reclaimed after drop — the carrier DecRef runs.""" |
| echo = tvm_ffi.convert(lambda x: x) |
| |
| class Payload: |
| __slots__ = ("__weakref__", "v") |
| |
| def __init__(self, v: int) -> None: |
| self.v = v |
| |
| refs: list[weakref.ref[Any]] = [] |
| for i in range(50): |
| p = Payload(i) |
| wr = weakref.ref(p) |
| r = echo(p) # p crosses the FFI as an OpaquePyObject |
| assert r is p |
| del r, p |
| refs.append(wr) |
| gc.collect() |
| leaked = sum(1 for wr in refs if wr() is not None) |
| assert leaked == 0, f"OpaquePyObject carrier leaked {leaked}/50 payloads" |
| |
| |
| # --------------------------------------------------------------------------- |
| # GC integration with Inactive |
| # --------------------------------------------------------------------------- |
| class TestReviveWithCyclicGC: |
| """Inactive cached allocations are dead, untracked memory (``subtype_dealloc`` |
| untracks before inactivation, and ``tp_alloc`` re-tracks only on revival). |
| ``gc.collect()`` between a drop and a refetch must not crash: it must |
| neither traverse the untracked cached allocation nor trip over the revived object. |
| """ |
| |
| def test_gc_collect_inside_revive_loop(self) -> None: |
| """gc.collect() between drop and refetch does not crash.""" |
| outer = Outer(Inner(5)) |
| # A bad traversal of the untracked cached allocation crashes deterministically on |
| # the first collect, so a modest count suffices; the cost here is the |
| # gc.collect() itself (~13ms each in this env), not the FFI work. |
| for _ in range(10): |
| ref = outer.x |
| del ref |
| gc.collect() # cached allocation is inactive (untracked) between drops |
| assert outer.x.val == 5 |
| |
| def test_gc_collect_with_holder_keeping_chandle(self) -> None: |
| """gc.collect() around inactive memory keeps id() stable.""" |
| # The Inner chandle is held by ``outer``'s field for the lifetime |
| # of the test, so wrapper drops always inactivate the allocation. gc.collect |
| # between drops exercises GC stability around the inactive memory. |
| outer = Outer(Inner(11)) |
| first_id = id(outer.x) |
| # GC stability around the inactive cached allocation is deterministic per cycle; |
| # a modest count surfaces it. Dominated by gc.collect() (~13ms each). |
| for _ in range(10): |
| _ = outer.x |
| gc.collect() |
| assert id(outer.x) == first_id |
| |
| |
| # --------------------------------------------------------------------------- |
| # Isolation: many distinct chandles each Inactive at once |
| # --------------------------------------------------------------------------- |
| class TestMultipleChandlesIsolation: |
| """A revive on one chandle must not corrupt the cached binding on |
| another. Each chandle's header is independent — verify by cycling |
| many in parallel. |
| """ |
| |
| def test_distinct_chandles_revive_independently(self) -> None: |
| """Each chandle revives to its own address and value independently.""" |
| outers = [Outer(Inner(i * 10)) for i in range(50)] |
| # Capture Active addresses, then drop to Inactive. |
| b_ids = [id(o.x) for o in outers] |
| gc.collect() |
| # Revive each independently; each must return its own preserved |
| # address with its own value. |
| for i, o in enumerate(outers): |
| revived = o.x |
| assert id(revived) == b_ids[i] |
| assert revived.val == i * 10 |
| |
| def test_interleaved_revives_do_not_cross_contaminate(self) -> None: |
| """Reviving in a different order keeps ids matched per-chandle.""" |
| # Build N outers, capture ids, drop all, then revive in a |
| # different order. Ids must still match per-chandle. |
| outers = [Outer(Inner(i)) for i in range(30)] |
| ids = [id(o.x) for o in outers] |
| gc.collect() |
| for i in reversed(range(30)): |
| assert id(outers[i].x) == ids[i] |
| |
| |
| # --------------------------------------------------------------------------- |
| # Weakref limitation (sentinel) |
| # --------------------------------------------------------------------------- |
| class TestWeakrefNotSupported: |
| """``CObject`` is a Cython cdef class without a ``__weakref__`` slot, |
| so weakrefs are not supported. This test documents that limitation: |
| if it ever starts passing, the Inactive-state interaction needs |
| design review (a weakref to an Active wrapper transitioning to Inactive |
| must behave correctly — the wrapper genuinely dies at refcount 0, so |
| old weakrefs should observe death and a revived wrapper should bind a |
| fresh weakref). |
| """ |
| |
| def test_weakref_raises_typeerror(self) -> None: |
| """Taking a weakref to a wrapper raises TypeError (documented limit).""" |
| outer = Outer(Inner(1)) |
| with pytest.raises(TypeError): |
| weakref.ref(outer.x) |