Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 15 additions & 13 deletions graphblas/core/operator/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,16 +534,13 @@ class TypedOpBase:
"gb_name",
"_type2",
"_jit_c_info",
"_owns_gb_obj_inst",
"_gb_obj_owner",
"__weakref__",
)
# Subclasses whose ``gb_obj`` was allocated via ``GrB_<Type>_new`` /
# ``GxB_<Type>_new`` (TypedUser*Op, _BoundIndexBinaryOp) override this so
# ``__del__`` frees the SuiteSparse handle. Built-in typed ops point at
# SuiteSparse's permanent built-in singletons and must never free.
# Specific instances can override via ``_owns_gb_obj_inst`` (set by
# the constructor); ``SelectOp._from_indexunary`` aliases an existing
# ``GrB_IndexUnaryOp`` and must clear ownership to avoid a double free.
_owns_gb_obj = False

def __init__(self, parent, name, type_, return_type, gb_obj, gb_name, dtype2=None):
Expand All @@ -558,10 +555,12 @@ def __init__(self, parent, name, type_, return_type, gb_obj, gb_name, dtype2=Non
# for this typed op; ``None`` for built-in ops and for UDT ops with
# no JIT path.
self._jit_c_info = None
# Per-instance ownership override; defaults to the class attribute.
# ``SelectOp._from_indexunary`` flips this to ``False`` on aliasing
# TypedUserSelectOps so only the IndexUnaryOp frees the handle.
self._owns_gb_obj_inst = type(self)._owns_gb_obj
# Set when ``gb_obj`` is borrowed from another typed op rather than
# allocated here (``SelectOp._from_indexunary``). Storing the owner
# both suppresses our free and keeps the handle alive as long as we
# point at it; disclaiming ownership without naming an owner would
# leave this op holding a dangling handle.
self._gb_obj_owner = None

@property
def jit_c_name(self):
Expand Down Expand Up @@ -594,11 +593,14 @@ def __reduce__(self):

def __del__(self):
# Free the SuiteSparse handle we allocated. Built-in typed ops alias
# SuiteSparse's permanent built-in singletons and must never free, so
# gate on the per-instance owns flag (defaults to the class
# attribute; the alias case overrides to False). Mirrors the
# ``Matrix.__del__`` / ``Vector.__del__`` pattern.
if not getattr(self, "_owns_gb_obj_inst", False):
# SuiteSparse's permanent built-in singletons and must never free.
# Mirrors the ``Matrix.__del__`` / ``Vector.__del__`` pattern.
if not type(self)._owns_gb_obj:
return
# A borrowed handle belongs to ``_gb_obj_owner``, which we keep alive.
# ``getattr`` guards the case where ``__init__`` raised before the slot
# was set.
if getattr(self, "_gb_obj_owner", None) is not None:
return
gb_obj = getattr(self, "gb_obj", None)
if gb_obj is None or lib is None or ffi is None:
Expand Down
17 changes: 12 additions & 5 deletions graphblas/core/operator/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ def __call__(self, val, thunk=None):
class TypedUserSelectOp(TypedOpBase):
__slots__ = ()
opclass = "SelectOp"
_owns_gb_obj = True # underlying object is a GrB_IndexUnaryOp
# The underlying object is a ``GrB_IndexUnaryOp``. Instances built by
# ``_from_indexunary`` borrow that handle and set ``_gb_obj_owner``, which
# suppresses the free here. Freeing from both sides is a use-after-free that
# no test catches: the freed handle usually still reads as valid.
_owns_gb_obj = True

def __init__(self, parent, name, type_, return_type, gb_obj, dtype2=None):
super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2)
Expand Down Expand Up @@ -97,10 +101,13 @@ def _from_indexunary(cls, iop):
t.return_type,
t.gb_obj,
)
# Aliases the IndexUnaryOp's allocation. The IndexUnaryOp
# owns the free; clearing here prevents a double free when
# both ends are GC'd.
op._owns_gb_obj_inst = False
# Borrow the IndexUnaryOp's allocation instead of making a
# second one. Holding ``t`` keeps that handle alive for as long
# as this SelectOp can use it: ``iop`` is a temporary in
# ``register_anonymous``, so without this the handle is freed
# the moment it is collected and every call raises
# UninitializedObject.
op._gb_obj_owner = t
else:
op = cls._typed_class(
obj,
Expand Down
21 changes: 21 additions & 0 deletions graphblas/tests/test_op.py
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,27 @@ def _this_or_that(val, idx, _, thunk): # pragma: no cover (numba)
assert result.isequal(w)


@pytest.mark.skipif("not supports_udfs")
def test_select_op_outlives_source_indexunary():
"""A SelectOp keeps alive the IndexUnaryOp whose GraphBLAS handle it borrows.

``SelectOp._from_indexunary`` reuses the IndexUnaryOp's ``gb_obj`` rather
than allocating a second one, and ``register_anonymous`` drops that
IndexUnaryOp on the way out. Without an explicit reference the handle is
freed as soon as it is collected, and every use of the SelectOp raises
``UninitializedObject``.
"""
import gc

def _ne_thunk(x, i, j, thunk): # pragma: no cover (numba)
return x != thunk

sel = SelectOp.register_anonymous(_ne_thunk)
gc.collect()
v = Vector.from_coo([0, 1, 2], [1, 5, 9])
assert v.select(sel, 5).new().isequal(Vector.from_coo([0, 2], [1, 9], size=3))


@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_udt_tuple_return_binaryop(record_udt):
Expand Down
Loading