Skip to content

Commit e3bc6bb

Browse files
committed
WIP: Handle dangling objects and rv_policy changes robustly
This is a potential answer to wjakob#888, also inspired by the discussion in wjakob#589. Seeking feedback on the approach before I work on docs and tests. When returning unique or shared ownership to Python when some Python instances already hold a non-owning reference to the same C++ object, we have a few choices: * Create a new owning instance in addition to the existing non-owning one. (This PR's approach.) * Upgrade the existing non-owning instance to owning. (More appealing in some ways, but hard to implement with shared ownership since nanobind doesn't have holders.) * Just return the non-owning instance and forget about the ownership. (nanobind's current approach, which sometimes causes problems.) This PR also handles the fact that a non-owning instance can dangle. That is, the referenced C++ object could be destroyed while the Python instance is still alive -- especially on PyPy, where it's hard to control when a Python instance dies. Dangling instances are always non-owning, so they are mostly handled by the same logic that handles rv_policy changes. The remaining piece of support for dangling instances is to acknowledge that, if the C++ referent is freed, a new instance could be allocated with its same address, even one with internal storage. (I have observed this in production.) So, there is some new logic in `inst_new_int` to remove the previous must-be-dangling instances from `inst_c2p`, rather than crashing because they exist. This also implies a new state that a nanobind instance can be in: if inst->offset == 0, the instance refers to no C++ object and is not stored in the inst_c2p map.
1 parent 534fd8c commit e3bc6bb

File tree

8 files changed

+241
-67
lines changed

8 files changed

+241
-67
lines changed

docs/api_core.rst

+4-2
Original file line numberDiff line numberDiff line change
@@ -2887,7 +2887,8 @@ The documentation below refers to two per-instance flags with the following mean
28872887
set to ``true`` and ``false``.
28882888

28892889
This is analogous to casting a C++ object with return value policy
2890-
:cpp:enumerator:`rv_policy::reference`.
2890+
:cpp:enumerator:`rv_policy::reference`, except that it will create a
2891+
new Python instance even if one wrapping this object already exists.
28912892

28922893
If a `parent` object is specified, the instance keeps this parent alive
28932894
while the newly created object exists. This is analogous to casting a C++
@@ -2904,7 +2905,8 @@ The documentation below refers to two per-instance flags with the following mean
29042905
``true``.
29052906

29062907
This is analogous to casting a C++ object with return value policy
2907-
:cpp:enumerator:`rv_policy::take_ownership`.
2908+
:cpp:enumerator:`rv_policy::take_ownership`, except that it will create a
2909+
new Python instance even if one wrapping this object already exists.
29082910

29092911
.. cpp:function:: void inst_zero(handle h)
29102912

docs/changelog.rst

+7
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ Version TBD (not yet released)
6565
not be an instance of the alias/trampoline type.
6666
(PR `#859 <https://github.com/wjakob/nanobind/pull/859>`__)
6767

68+
* Nanobind now avoids returning an existing non-owning Python instance when
69+
an owning Python instance was requested. Instead, it will create a new
70+
Python instance that holds (unique or shared) ownership of the C++ object,
71+
and allow the existing instance to continue to hold a reference to it.
72+
This also helps in situations where <TODO...>
73+
(PR `#889 <https://github.com/wjakob/nanobind/pull/889>`__)
74+
6875
Version 2.4.0 (Dec 6, 2024)
6976
---------------------------
7077

include/nanobind/intrusive/ref.h

+13-2
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,23 @@ template <typename T> struct type_caster<nanobind::ref<T>> {
139139

140140
static handle from_cpp(const ref<T> &value, rv_policy policy,
141141
cleanup_list *cleanup) noexcept {
142+
bool force_new = policy == rv_policy::copy || policy == rv_policy::move;
143+
142144
if constexpr (std::is_base_of_v<intrusive_base, T>)
143-
if (policy != rv_policy::copy && policy != rv_policy::move && value.get())
145+
if (!force_new && value.get())
144146
if (PyObject* obj = value->self_py())
145147
return handle(obj).inc_ref();
146148

147-
return Caster::from_cpp(value.get(), policy, cleanup);
149+
/* The combination of rv_policy::_shared_ownership + not asking
150+
for an is_new response, is treated as requiring intrusive ownership.
151+
We could pass any policy here except copy/move, but using this one
152+
allows nanobind to check that the intrusive_ptr() annotation was
153+
specified correctly. */
154+
if (!force_new)
155+
policy = rv_policy::_shared_ownership;
156+
157+
return Caster::from_cpp_raw((std::decay_t<T> *) value.get(), policy,
158+
cleanup, nullptr);
148159
}
149160
};
150161
NAMESPACE_END(detail)

include/nanobind/nb_cast.h

+10-3
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,14 @@ template <typename Type_> struct type_caster_base : type_caster_base_tag {
481481
else
482482
ptr = (Type *) &value;
483483

484-
policy = infer_policy<T>(policy);
484+
return from_cpp_raw(ptr, infer_policy<T>(policy), cleanup, nullptr);
485+
}
486+
487+
/* Version of from_cpp that uses the rv_policy you give it as-is,
488+
without resolving through infer_policy */
489+
NB_INLINE static handle from_cpp_raw(Type *ptr, rv_policy policy,
490+
cleanup_list *cleanup,
491+
bool *is_new) noexcept {
485492
const std::type_info *type = &typeid(Type);
486493

487494
constexpr bool has_type_hook =
@@ -490,11 +497,11 @@ template <typename Type_> struct type_caster_base : type_caster_base_tag {
490497
type = type_hook<Type>::get(ptr);
491498

492499
if constexpr (!std::is_polymorphic_v<Type>) {
493-
return nb_type_put(type, ptr, policy, cleanup);
500+
return nb_type_put(type, ptr, policy, cleanup, is_new);
494501
} else {
495502
const std::type_info *type_p =
496503
(!has_type_hook && ptr) ? &typeid(*ptr) : nullptr;
497-
return nb_type_put_p(type, type_p, ptr, policy, cleanup);
504+
return nb_type_put_p(type, type_p, ptr, policy, cleanup, is_new);
498505
}
499506
}
500507

include/nanobind/nb_enums.h

+16-1
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,24 @@ enum class rv_policy {
1818
move,
1919
reference,
2020
reference_internal,
21-
none
21+
none,
2222
/* Note to self: nb_func.h assumes that this value fits into 3 bits,
2323
hence no further policies can be added. */
24+
25+
/* Special internal-use policy that can only be passed to `nb_type_put()`
26+
or `type_caster_base<T>::from_cpp_raw()`. (To keep rv_policy fitting
27+
in 3 bits, it aliases `automatic_reference`, which would always have
28+
been converted into `copy` or `move` or `reference` before reaching
29+
those functions.) It means we are creating a Python instance that holds
30+
shared ownership of the C++ object we're casting, so we shouldn't reuse
31+
a Python instance that only holds a reference to that object. The actual
32+
enforcement of the shared ownership must be done separately after the
33+
cast completes, via a keep_alive callback or similar, to ensure that
34+
the C++ object lives at least as long as the new Python instance does.
35+
36+
This is an internal tool that is not part of nanobind's public API,
37+
and may be changed without notice. */
38+
_shared_ownership = automatic_reference
2439
};
2540

2641
NAMESPACE_END(NB_NAMESPACE)

include/nanobind/stl/shared_ptr.h

+2-17
Original file line numberDiff line numberDiff line change
@@ -102,23 +102,8 @@ template <typename T> struct type_caster<std::shared_ptr<T>> {
102102
handle result;
103103

104104
Td *ptr = (Td *) value.get();
105-
const std::type_info *type = &typeid(Td);
106-
107-
constexpr bool has_type_hook =
108-
!std::is_base_of_v<std::false_type, type_hook<Td>>;
109-
if constexpr (has_type_hook)
110-
type = type_hook<Td>::get(ptr);
111-
112-
if constexpr (!std::is_polymorphic_v<Td>) {
113-
result = nb_type_put(type, ptr, rv_policy::reference,
114-
cleanup, &is_new);
115-
} else {
116-
const std::type_info *type_p =
117-
(!has_type_hook && ptr) ? &typeid(*ptr) : nullptr;
118-
119-
result = nb_type_put_p(type, type_p, ptr, rv_policy::reference,
120-
cleanup, &is_new);
121-
}
105+
result = Caster::from_cpp_raw(ptr, rv_policy::_shared_ownership,
106+
cleanup, &is_new);
122107

123108
if (is_new) {
124109
std::shared_ptr<void> pp;

src/nb_internals.h

+8-2
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ struct func_data : func_data_prelim<0> {
5151
struct nb_inst { // usually: 24 bytes
5252
PyObject_HEAD
5353

54-
/// Offset to the actual instance data
54+
/// Offset to the actual C++ object. A value of zero here is special;
55+
/// it means this instance was "detached" (assumed dangling) because its
56+
/// C++ object address collided with a newly allocated internal instance.
57+
/// Detached instances are not stored in the inst_c2p map.
5558
int32_t offset;
5659

5760
/// State of the C++ object this instance points to: is it constructed?
@@ -87,8 +90,11 @@ struct nb_inst { // usually: 24 bytes
8790
/// Does this instance use intrusive reference counting?
8891
uint32_t intrusive : 1;
8992

93+
/// Does this instance hold partial/shared ownership of its C++ object?
94+
uint32_t shared_ownership : 1;
95+
9096
// That's a lot of unused space. I wonder if there is a good use for it..
91-
uint32_t unused : 24;
97+
uint32_t unused : 23;
9298
};
9399

94100
static_assert(sizeof(nb_inst) == sizeof(PyObject) + sizeof(uint32_t) * 2);

0 commit comments

Comments
 (0)