From 036c91c1a7dc11b1d8697a925d40154f72db9618 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Sat, 16 Aug 2025 17:53:12 -0600 Subject: [PATCH 1/8] Add interoperability with other Python binding frameworks --- CMakeLists.txt | 1 + cmake/nanobind-config.cmake | 11 +- docs/api_cmake.rst | 4 + include/nanobind/nb_class.h | 18 +- include/nanobind/nb_error.h | 4 +- include/nanobind/nb_lib.h | 20 +- include/nanobind/nb_types.h | 10 +- include/nanobind/stl/unique_ptr.h | 5 + src/error.cpp | 28 +- src/nb_abi.h | 2 +- src/nb_enum.cpp | 45 +- src/nb_foreign.cpp | 526 ++++++++++++++++++++++ src/nb_func.cpp | 33 +- src/nb_internals.cpp | 63 ++- src/nb_internals.h | 417 ++++++++++++++++-- src/nb_type.cpp | 652 ++++++++++++++++++++------- src/pymetabind.h | 709 ++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 4 + 18 files changed, 2275 insertions(+), 277 deletions(-) create mode 100644 src/nb_foreign.cpp create mode 100644 src/pymetabind.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c1ebdf23..4ab60d014 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ option(NB_TEST_STABLE_ABI "Test the stable ABI interface?" OFF) option(NB_TEST_SHARED_BUILD "Build a shared nanobind library for the test suite?" OFF) option(NB_TEST_CUDA "Force the use of the CUDA/NVCC compiler for testing purposes" OFF) option(NB_TEST_FREE_THREADED "Build free-threaded extensions for the test suite?" ON) +option(NB_TEST_NO_INTEROP "Build without framework interoperability support?" OFF) if (NOT MSVC) option(NB_TEST_SANITIZERS_ASAN "Build tests with the address sanitizer?" OFF) diff --git a/cmake/nanobind-config.cmake b/cmake/nanobind-config.cmake index 250d31c5d..63a98ca91 100644 --- a/cmake/nanobind-config.cmake +++ b/cmake/nanobind-config.cmake @@ -190,6 +190,7 @@ function (nanobind_build_library TARGET_NAME) ${NB_DIR}/src/nb_static_property.cpp ${NB_DIR}/src/nb_ft.h ${NB_DIR}/src/nb_ft.cpp + ${NB_DIR}/src/nb_foreign.cpp ${NB_DIR}/src/common.cpp ${NB_DIR}/src/error.cpp ${NB_DIR}/src/trampoline.cpp @@ -236,6 +237,10 @@ function (nanobind_build_library TARGET_NAME) target_compile_definitions(${TARGET_NAME} PUBLIC NB_FREE_THREADED) endif() + if (TARGET_NAME MATCHES "-local") + target_compile_definitions(${TARGET_NAME} PRIVATE NB_DISABLE_FOREIGN) + endif() + # Nanobind performs many assertion checks -- detailed error messages aren't # included in Release/MinSizeRel/RelWithDebInfo modes target_compile_definitions(${TARGET_NAME} PRIVATE @@ -330,7 +335,7 @@ endfunction() function(nanobind_add_module name) cmake_parse_arguments(PARSE_ARGV 1 ARG - "STABLE_ABI;FREE_THREADED;NB_STATIC;NB_SHARED;PROTECT_STACK;LTO;NOMINSIZE;NOSTRIP;MUSL_DYNAMIC_LIBCPP;NB_SUPPRESS_WARNINGS" + "STABLE_ABI;FREE_THREADED;NB_STATIC;NB_SHARED;PROTECT_STACK;LTO;NOMINSIZE;NOSTRIP;MUSL_DYNAMIC_LIBCPP;NB_SUPPRESS_WARNINGS;NO_INTEROP" "NB_DOMAIN" "") add_library(${name} MODULE ${ARG_UNPARSED_ARGUMENTS}) @@ -375,6 +380,10 @@ function(nanobind_add_module name) set(libname "${libname}-ft") endif() + if (ARG_NO_INTEROP) + set(libname "${libname}-local") + endif() + if (ARG_NB_DOMAIN AND ARG_NB_SHARED) set(libname ${libname}-${ARG_NB_DOMAIN}) endif() diff --git a/docs/api_cmake.rst b/docs/api_cmake.rst index 1547535b4..486ad6eec 100644 --- a/docs/api_cmake.rst +++ b/docs/api_cmake.rst @@ -110,6 +110,10 @@ The high-level interface consists of just one CMake command: an optimization that nanobind does by default in this specific case). If this explanation sounds confusing, then you can ignore it. See the detailed description below for more information on this step. + * - ``NO_INTEROP`` + - Remove support for interoperability with other Python binding + frameworks. If you don't need it in your environment, this offers + a minor performance and code size benefit. :cmake:command:`nanobind_add_module` performs the following steps to produce bindings. diff --git a/include/nanobind/nb_class.h b/include/nanobind/nb_class.h index 8260299e3..6e28ef38f 100644 --- a/include/nanobind/nb_class.h +++ b/include/nanobind/nb_class.h @@ -91,9 +91,6 @@ enum class type_init_flags : uint32_t { all_init_flags = (0x1f << 19) }; -// See internals.h -struct nb_alias_chain; - // Implicit conversions for C++ type bindings, used in type_data below struct implicit_t { const std::type_info **cpp; @@ -114,7 +111,7 @@ struct type_data { const char *name; const std::type_info *type; PyTypeObject *type_py; - nb_alias_chain *alias_chain; + void *foreign_bindings; #if defined(Py_LIMITED_API) PyObject* (*vectorcall)(PyObject *, PyObject * const*, size_t, PyObject *); #endif @@ -332,6 +329,19 @@ inline void *type_get_slot(handle h, int slot_id) { #endif } +// nanobind interoperability with other binding frameworks +inline void set_foreign_type_defaults(bool export_all, bool import_all) { + detail::nb_type_set_foreign_defaults(export_all, import_all); +} +template +inline void import_foreign_type(handle type) { + detail::nb_type_import(type.ptr(), + std::is_void_v ? nullptr : &typeid(T)); +} +inline void export_type_to_foreign(handle type) { + detail::nb_type_export(type.ptr()); +} + template struct def_visitor { protected: // Ensure def_visitor can only be derived from, not constructed diff --git a/include/nanobind/nb_error.h b/include/nanobind/nb_error.h index 3abc960ec..297f6143c 100644 --- a/include/nanobind/nb_error.h +++ b/include/nanobind/nb_error.h @@ -125,7 +125,7 @@ NB_EXCEPTION(next_overload) inline void register_exception_translator(detail::exception_translator t, void *payload = nullptr) { - detail::register_exception_translator(t, payload); + detail::register_exception_translator(t, payload, /*at_end=*/false); } template @@ -142,7 +142,7 @@ class exception : public object { } catch (T &e) { PyErr_SetString((PyObject *) payload, e.what()); } - }, m_ptr); + }, m_ptr, /*at_end=*/false); } }; diff --git a/include/nanobind/nb_lib.h b/include/nanobind/nb_lib.h index 356a7ca34..7a5ce4a12 100644 --- a/include/nanobind/nb_lib.h +++ b/include/nanobind/nb_lib.h @@ -341,10 +341,12 @@ NB_CORE const std::type_info *nb_type_info(PyObject *t) noexcept; NB_CORE void *nb_inst_ptr(PyObject *o) noexcept; /// Check if a Python type object wraps an instance of a specific C++ type -NB_CORE bool nb_type_isinstance(PyObject *obj, const std::type_info *t) noexcept; +NB_CORE bool nb_type_isinstance(PyObject *obj, const std::type_info *t, + bool foreign_ok) noexcept; -/// Search for the Python type object associated with a C++ type -NB_CORE PyObject *nb_type_lookup(const std::type_info *t) noexcept; +/// Search for a Python type object associated with a C++ type +NB_CORE PyObject *nb_type_lookup(const std::type_info *t, + bool foreign_ok) noexcept; /// Allocate an instance of type 't' NB_CORE PyObject *nb_inst_alloc(PyTypeObject *t); @@ -386,6 +388,15 @@ NB_CORE void nb_inst_set_state(PyObject *o, bool ready, bool destruct) noexcept; /// Query the 'ready' and 'destruct' flags of an instance NB_CORE std::pair nb_inst_state(PyObject *o) noexcept; +// Set whether types will be shared with other binding frameworks by default +NB_CORE void nb_type_set_foreign_defaults(bool export_all, bool import_all); + +// Teach nanobind about a type bound by another binding framework +NB_CORE void nb_type_import(PyObject *pytype, const std::type_info *cpptype); + +// Teach other binding frameworks about a type bound by nanobind +NB_CORE void nb_type_export(PyObject *pytype); + // ======================================================================== // Create and install a Python property object @@ -500,7 +511,8 @@ NB_CORE void print(PyObject *file, PyObject *str, PyObject *end); typedef void (*exception_translator)(const std::exception_ptr &, void *); NB_CORE void register_exception_translator(exception_translator translator, - void *payload); + void *payload, + bool at_end); NB_CORE PyObject *exception_new(PyObject *mod, const char *name, PyObject *base); diff --git a/include/nanobind/nb_types.h b/include/nanobind/nb_types.h index 0d5970731..4e8d27ed7 100644 --- a/include/nanobind/nb_types.h +++ b/include/nanobind/nb_types.h @@ -667,15 +667,19 @@ class iterable : public object { /// Retrieve the Python type object associated with a C++ class template handle type() noexcept { - return detail::nb_type_lookup(&typeid(detail::intrinsic_t)); + return detail::nb_type_lookup(&typeid(detail::intrinsic_t), false); +} +template handle maybe_foreign_type() noexcept { + return detail::nb_type_lookup(&typeid(detail::intrinsic_t), true); } template -NB_INLINE bool isinstance(handle h) noexcept { +NB_INLINE bool isinstance(handle h, bool foreign_ok = false) noexcept { if constexpr (std::is_base_of_v) return T::check_(h); else if constexpr (detail::is_base_caster_v>) - return detail::nb_type_isinstance(h.ptr(), &typeid(detail::intrinsic_t)); + return detail::nb_type_isinstance(h.ptr(), &typeid(detail::intrinsic_t), + foreign_ok); else return detail::make_caster().from_python(h, 0, nullptr); } diff --git a/include/nanobind/stl/unique_ptr.h b/include/nanobind/stl/unique_ptr.h index c7700f3c2..fac64047c 100644 --- a/include/nanobind/stl/unique_ptr.h +++ b/include/nanobind/stl/unique_ptr.h @@ -94,6 +94,11 @@ struct type_caster> { // Stash source python object src = src_; + // Don't accept foreign types; they can't relinquish ownership + if (!src.is_none() && !inst_check(src)) { + return false; + } + /* Try casting to a pointer of the underlying type. We pass flags=0 and cleanup=nullptr to prevent implicit type conversions (they are problematic since the instance then wouldn't be owned by 'src') */ diff --git a/src/error.cpp b/src/error.cpp index 5d1d6666e..42664d37b 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -217,12 +217,28 @@ builtin_exception::~builtin_exception() { } NAMESPACE_BEGIN(detail) -void register_exception_translator(exception_translator t, void *payload) { - nb_translator_seq *cur = &internals->translators, - *next = new nb_translator_seq(*cur); - cur->next = next; - cur->payload = payload; - cur->translator = t; +void register_exception_translator(exception_translator t, + void *payload, + bool at_end) { + // We will insert the new translator so it is pointed to by `*insert_at`, + // i.e., so that it is executed just before the current `*insert_at` + nb_maybe_atomic *insert_at = &internals->translators; + if (at_end) { + // Insert before the default exception translator (which is last in + // the list) + nb_translator_seq *next = insert_at->load_acquire(); + while (next && next->next.load_relaxed()) { + insert_at = &next->next; + next = insert_at->load_acquire(); + } + } + nb_translator_seq *new_head = new nb_translator_seq{}; + nb_translator_seq *cur_head = insert_at->load_relaxed(); + new_head->payload = payload; + new_head->translator = t; + do { + new_head->next.store_release(cur_head); + } while (!insert_at->compare_exchange_weak(cur_head, new_head)); } NB_CORE PyObject *exception_new(PyObject *scope, const char *name, diff --git a/src/nb_abi.h b/src/nb_abi.h index da704d99f..daf09ddb7 100644 --- a/src/nb_abi.h +++ b/src/nb_abi.h @@ -14,7 +14,7 @@ /// Tracks the version of nanobind's internal data structures #ifndef NB_INTERNALS_VERSION -# define NB_INTERNALS_VERSION 16 +# define NB_INTERNALS_VERSION 17 #endif #if defined(__MINGW32__) diff --git a/src/nb_enum.cpp b/src/nb_enum.cpp index 427c0d85d..1319d998c 100644 --- a/src/nb_enum.cpp +++ b/src/nb_enum.cpp @@ -18,23 +18,6 @@ using enum_map = tsl::robin_map; PyObject *enum_create(enum_init_data *ed) noexcept { // Update hash table that maps from std::type_info to Python type - nb_internals *internals_ = internals; - bool success; - nb_type_map_slow::iterator it; - - { - lock_internals guard(internals_); - std::tie(it, success) = internals_->type_c2p_slow.try_emplace(ed->type, nullptr); - if (!success) { - PyErr_WarnFormat(PyExc_RuntimeWarning, 1, - "nanobind: type '%s' was already registered!\n", - ed->name); - PyObject *tp = (PyObject *) it->second->type_py; - Py_INCREF(tp); - return tp; - } - } - handle scope(ed->scope); bool is_arithmetic = ed->flags & (uint32_t) enum_flags::is_arithmetic; @@ -85,20 +68,7 @@ PyObject *enum_create(enum_init_data *ed) noexcept { t->enum_tbl.rev = new enum_map(); t->scope = ed->scope; - it.value() = t; - - { - lock_internals guard(internals_); - internals_->type_c2p_slow[ed->type] = t; - - #if !defined(NB_FREE_THREADED) - internals_->type_c2p_fast[ed->type] = t; - #endif - } - - make_immortal(result.ptr()); - - result.attr("__nb_enum__") = capsule(t, [](void *p) noexcept { + capsule tie_lifetimes(t, [](void *p) noexcept { type_init_data *t = (type_init_data *) p; delete (enum_map *) t->enum_tbl.fwd; delete (enum_map *) t->enum_tbl.rev; @@ -107,6 +77,19 @@ PyObject *enum_create(enum_init_data *ed) noexcept { delete t; }); + if (type_data *conflict; !nb_type_register(t, &conflict)) { + PyErr_WarnFormat(PyExc_RuntimeWarning, 1, + "nanobind: type '%s' was already registered!\n", + ed->name); + PyObject *tp = (PyObject *) conflict->type_py; + Py_INCREF(tp); + return tp; + } + + result.attr("__nb_enum__") = tie_lifetimes; + + make_immortal(result.ptr()); + return result.release().ptr(); } diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp new file mode 100644 index 000000000..79343cdc6 --- /dev/null +++ b/src/nb_foreign.cpp @@ -0,0 +1,526 @@ +/* + src/nb_foreign.cpp: libnanobind functionality for interfacing with other + binding libraries + + Copyright (c) 2025 Hudson River Trading LLC + + All rights reserved. Use of this source code is governed by a + BSD-style license that can be found in the LICENSE file. +*/ + +#if !defined(NB_DISABLE_FOREIGN) + +#include "nb_internals.h" +#include "nb_ft.h" +#include "nb_abi.h" + +NAMESPACE_BEGIN(NB_NAMESPACE) +NAMESPACE_BEGIN(detail) + +// nanobind exception translator that wraps a foreign one +static void foreign_exception_translator(const std::exception_ptr &p, + void *payload) { + ((pymb_framework *) payload)->translate_exception(&p); +} + +// When learning about a new foreign type, should we automatically use it? +NB_INLINE bool should_autoimport_foreign(nb_internals *internals_, + pymb_binding *binding) { + return internals_->foreign_import_all && + binding->framework->abi_lang == pymb_abi_lang_cpp && + binding->framework->abi_extra == internals_->foreign_self->abi_extra; +} + +static void nb_type_import_binding(pymb_binding *binding, + const std::type_info *cpptype) noexcept; + +// Callback functions for other frameworks to operate on our objects +// or tell us about theirs + +static void *nb_foreign_from_python(pymb_binding *binding, + PyObject *pyobj, + uint8_t convert, + void (*keep_referenced)(void *ctx, + PyObject *obj), + void *keep_referenced_ctx) noexcept { + cleanup_list cleanup{nullptr}; + auto *td = (type_data *) binding->context; + void *result = nullptr; + bool ok = nb_type_get(td->type, pyobj, + convert ? uint8_t(cast_flags::convert) : 0, + keep_referenced ? &cleanup : nullptr, &result); + if (keep_referenced) { + for (uint32_t idx = 1; idx < cleanup.size(); ++idx) + keep_referenced(keep_referenced_ctx, cleanup[idx]); + if (cleanup.size() > 1) + cleanup.release(); + } + return ok ? result : nullptr; +} + +static PyObject *nb_foreign_to_python(pymb_binding *binding, + void *cobj, + enum pymb_rv_policy rvp_, + PyObject *parent) noexcept { + cleanup_list cleanup{parent}; + auto *td = (type_data *) binding->context; + rv_policy rvp = (rv_policy) rvp_; + if (rvp > rv_policy::none) + rvp = rv_policy::none; + return nb_type_put(td->type, cobj, rvp, &cleanup, nullptr); +} + +static int nb_foreign_keep_alive(PyObject *nurse, + void *payload, + void (*cb)(void*)) noexcept { + try { + if (cb) + keep_alive(nurse, payload, (void (*)(void*) noexcept) cb); + else + keep_alive(nurse, (PyObject *) payload); + return true; + } catch (const std::runtime_error& err) { + PyErr_SetString(PyExc_RuntimeError, err.what()); + return false; + } +} + +static void nb_foreign_translate_exception(const void *eptr) { + std::exception_ptr e = *(const std::exception_ptr *) eptr; + for (nb_translator_seq* cur = internals->translators.load_acquire(); + cur; cur = cur->next.load_acquire()) { + if (cur->translator == default_exception_translator || + cur->translator == foreign_exception_translator) { + // The default translator translates generic STL exceptions which + // other frameworks might want to translate differently than we do; + // they should get control over the behavior of their functions. + // Don't call foreign translators to avoid mutual recursion. + // Both these are at the end of the list, so we can stop iterating + // when we see one. + break; + } + try { + cur->translator(e, cur->payload); + return; + } catch (...) { e = std::current_exception(); } + } + + // Check nb::python_error and nb::builtin_exception + try { + std::rethrow_exception(e); + } catch (python_error &e) { + e.restore(); + } catch (builtin_exception &e) { + if (!set_builtin_exception_status(e)) + PyErr_SetString(PyExc_SystemError, "foreign function threw " + "nanobind::next_overload()"); + } + // Anything not caught by the above bubbles out. +} + +static void nb_foreign_add_foreign_binding(pymb_binding *binding) noexcept { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + if (should_autoimport_foreign(internals_, binding)) + nb_type_import_binding(binding, + (const std::type_info *) binding->native_type); +} + +static void nb_foreign_remove_foreign_binding(pymb_binding *binding) noexcept { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + + auto remove_from_list = [binding](void *list_head, + nb_foreign_seq **to_free) -> void* { + if (!nb_is_seq(list_head)) + return list_head == binding ? nullptr : list_head; + nb_foreign_seq *current = nb_get_seq(list_head); + nb_foreign_seq *prev = nullptr; + while (current && current->value != binding) { + prev = current; + current = nb_load_acquire(current->next); + } + if (current) { + *to_free = current; + nb_foreign_seq *next = nb_load_acquire(current->next); + if (!prev) + return next ? nb_mark_seq(next) : nullptr; + nb_store_release(prev->next, next); + } + return list_head; + }; + + auto remove_from_type = [=](const std::type_info *type) { + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; + auto it = type_c2p_slow.find(type); + check(it != type_c2p_slow.end(), + "foreign binding not registered upon removal"); + void *new_value = it->second; + nb_foreign_seq *to_free = nullptr; + if (nb_is_foreign(it->second)) { + new_value = remove_from_list(nb_get_foreign(it->second), &to_free); + if (new_value) + it.value() = new_value = nb_mark_foreign(new_value); + else + type_c2p_slow.erase(it); + } else { + auto *t = (type_data *) it->second; + nb_store_release(t->foreign_bindings, + remove_from_list( + nb_load_acquire(t->foreign_bindings), + &to_free)); + } + nb_type_update_c2p_fast(type, new_value); + PyMem_Free(to_free); + }; + + bool should_remove_auto = should_autoimport_foreign(internals_, binding); + if (auto it = internals_->foreign_manual_imports.find(binding); + it != internals_->foreign_manual_imports.end()) { + remove_from_type((const std::type_info *) it->second); + should_remove_auto &= (it->second != binding->native_type); + internals_->foreign_manual_imports.erase(it); + } + if (should_remove_auto) + remove_from_type((const std::type_info *) binding->native_type); +} + +static void nb_foreign_add_foreign_framework(pymb_framework *framework) + noexcept { + register_exception_translator(foreign_exception_translator, framework, + /*at_end=*/true); + internals->print_leak_warnings &= !framework->bindings_usable_forever; +} + +// (end of callbacks) + +// Advertise our existence, and the above callbacks, to other frameworks +static void register_with_pymetabind(nb_internals *internals_) { + // caller must hold the internals lock + if (internals_->foreign_registry) + return; + internals_->foreign_registry = pymb_get_registry(); + if (!internals_->foreign_registry) + raise_python_error(); + + auto *fw = new pymb_framework{}; + fw->name = "nanobind " NB_ABI_TAG; +#if defined(NB_FREE_THREADED) + fw->bindings_usable_forever = 1; +#else + fw->bindings_usable_forever = 0; +#endif + fw->abi_lang = pymb_abi_lang_cpp; + fw->abi_extra = NB_PLATFORM_ABI_TAG; + fw->from_python = nb_foreign_from_python; + fw->to_python = nb_foreign_to_python; + fw->keep_alive = nb_foreign_keep_alive; + fw->translate_exception = nb_foreign_translate_exception; + fw->add_foreign_binding = nb_foreign_add_foreign_binding; + fw->remove_foreign_binding = nb_foreign_remove_foreign_binding; + fw->add_foreign_framework = nb_foreign_add_foreign_framework; + internals_->foreign_self = fw; + + auto *registry = internals_->foreign_registry; + // pymb_add_framework() will call our add_foreign_framework and + // add_foreign_binding method for each existing other framework/binding; + // those need to lock internals, so unlock here + unlock_internals guard{internals_}; + pymb_add_framework(registry, fw); +} + +// Add the given `binding` to our type maps so that we can use it to satisfy +// from- and to-Python requests for the given C++ type +static void nb_type_import_binding(pymb_binding *binding, + const std::type_info *cpptype) noexcept { + // Caller must hold the internals lock + internals->foreign_imported_any = true; + + auto add_to_list = [binding](void *list_head) -> void* { + if (!list_head) { + return binding; + } + nb_foreign_seq *seq = nb_ensure_seq(&list_head); + while (true) { + if (seq->value == binding) + return list_head; // already added + nb_foreign_seq *next = nb_load_acquire(seq->next); + if (next == nullptr) + break; + seq = next; + } + nb_foreign_seq *next = + (nb_foreign_seq *) PyMem_Malloc(sizeof(nb_foreign_seq)); + check(next, "add_foreign_binding_to_list(): out of memory!"); + next->value = binding; + next->next = nullptr; + nb_store_release(seq->next, next); + return list_head; + }; + + auto [it, inserted] = internals->type_c2p_slow.try_emplace( + cpptype, nb_mark_foreign(binding)); + if (!inserted) { + if (nb_is_foreign(it->second)) + it.value() = nb_mark_foreign(add_to_list(nb_get_foreign(it->second))); + else if (auto *t = (type_data *) it->second) + nb_store_release(t->foreign_bindings, + add_to_list(nb_load_acquire(t->foreign_bindings))); + else + check(false, "null entry in type_c2p_slow"); + } + nb_type_update_c2p_fast(cpptype, it->second); +} + +// Learn to satisfy from- and to-Python requests for `cpptype` using the +// foreign binding provided by the given `pytype`. If cpptype is nullptr, infer +// the C++ type by looking at the binding, and require that its ABI match ours. +// Throws an exception on failure. Caller must hold the internals lock. +void nb_type_import_impl(PyObject *pytype, const std::type_info *cpptype) { + if (!internals->foreign_registry) + register_with_pymetabind(internals); + pymb_framework* foreign_self = internals->foreign_self; + pymb_binding* binding = pymb_get_binding(pytype); +#if defined(Py_LIMITED_API) + str name_py = steal(PyType_GetName((PyTypeObject *) pytype)); + const char *name = name_py.c_str(); +#else + const char *name = ((PyTypeObject *) pytype)->tp_name; +#endif + if (!binding) + raise("'%s' does not define a __pymetabind_binding__", name); + if (binding->framework == foreign_self) + raise("'%s' is already bound by this nanobind domain", name); + if (!cpptype) { + if (binding->framework->abi_lang != pymb_abi_lang_cpp) + raise("'%s' is not written in C++, so you must provide a C++ type", + name); + if (binding->framework->abi_extra != foreign_self->abi_extra) + raise("'%s' has incompatible C++ ABI with this nanobind domain: " + "their '%s' vs our '%s'", name, binding->framework->abi_extra, + foreign_self->abi_extra); + cpptype = (const std::type_info *) binding->native_type; + } + + auto [it, inserted] = internals->foreign_manual_imports.try_emplace( + (void *) binding, (void *) cpptype); + if (!inserted) { + auto *existing = (const std::type_info *) it->second; + if (existing != cpptype && *existing != *cpptype) + raise("'%s' was already mapped to C++ type '%s', so can't now " + "map it to '%s'", + name, existing->name(), cpptype->name()); + } + nb_type_import_binding(binding, cpptype); +} + +// Call `nb_type_import_binding()` for every ABI-compatible type provided by +// other C++ binding frameworks used by extension modules loaded in this +// interpreter, both those that exist now and those bound in the future. +void nb_type_enable_import_all() { + nb_internals *internals_ = internals; + { + lock_internals guard{internals_}; + internals_->foreign_import_all = true; + if (!internals_->foreign_registry) { + // pymb_add_framework tells us about every existing type when we + // register, so if we register with import enabled, we're done + register_with_pymetabind(internals_); + return; + } + } + // If we enable import after registering, we have to iterate over the + // list of types ourselves. Do this without the internals lock held so + // we can reuse the pymb callback functions. foreign_registry and + // foreign_self never change once they're non-null, so we can accesss them + // without locking here. + pymb_lock_registry(internals_->foreign_registry); + PYMB_LIST_FOREACH(struct pymb_binding*, binding, + internals_->foreign_registry->bindings) { + if (binding->framework != internals_->foreign_self && + pymb_try_ref_binding(binding)) { + nb_foreign_add_foreign_binding(binding); + pymb_unref_binding(binding); + } + } + pymb_unlock_registry(internals_->foreign_registry); +} + +// Expose hooks for other frameworks to use to work with the given nanobind +// type object. Caller must hold the internals lock. +void nb_type_export_impl(type_data *td) { + if (!internals->foreign_registry) + register_with_pymetabind(internals); + + void *foreign_bindings = nb_load_acquire(td->foreign_bindings); + if (nb_is_seq(foreign_bindings)) { + nb_foreign_seq *node = nb_get_seq(foreign_bindings); + if (node->value->framework == internals->foreign_self) + return; // already exported + } else if (auto *binding = (pymb_binding *) foreign_bindings; + binding && binding->framework == internals->foreign_self) + return; // already exporte + + auto binding = (pymb_binding *) PyMem_Malloc(sizeof(pymb_binding)); + binding->framework = internals->foreign_self; + binding->pytype = td->type_py; + binding->native_type = td->type; + binding->source_name = type_name(td->type); + binding->context = td; + + if (foreign_bindings) { + nb_foreign_seq *existing = + nb_ensure_seq(&foreign_bindings); + nb_foreign_seq *new_ = + (nb_foreign_seq *) PyMem_Malloc(sizeof(nb_foreign_seq)); + new_->value = binding; + new_->next = existing; + foreign_bindings = nb_mark_seq(new_); + } else { + foreign_bindings = binding; + } + nb_store_release(td->foreign_bindings, foreign_bindings); + pymb_add_binding(internals->foreign_registry, binding); + // No need to call nb_type_update_c2p_fast: the map value (`td`) hasn't + // changed, and a potential concurrent lookup that picked up the old value + // of `td->foreign_bindings` is safe. +} + +// Call `nb_type_export_impl()` for each type that currently exists in this +// nanobind domain and each type created in the future. +void nb_type_enable_export_all() { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + internals_->foreign_export_all = true; + if (!internals_->foreign_registry) + register_with_pymetabind(internals_); + for (const auto& [type, value] : internals_->type_c2p_slow) { + if (nb_is_foreign(value)) + continue; + nb_type_export_impl((type_data *) value); + } +} + +// Invoke `attempt(closure, binding)` for each foreign binding `binding` +// that claims `type` and was not supplied by us, until one of them returns +// non-null. Return that first non-null value, or null if all attempts failed. +// Requires that a previous call to nb_type_c2p() have been made for `type`. +void *nb_type_try_foreign(nb_internals *internals_, + const std::type_info *type, + void* (*attempt)(void *closure, + pymb_binding *binding), + void *closure) { + // It is not valid to reuse the lookup made by a previous nb_type_c2p(), + // because some bindings could have been removed between then and now. +#if defined(NB_FREE_THREADED) + auto per_thread_guard = nb_type_lock_c2p_fast(internals_); + nb_type_map_fast &type_c2p_fast = *per_thread_guard; +#else + nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; +#endif + + // We assume nb_type_c2p already ran for this type, so that there's + // no need to handle a cache miss here. + void *foreign_bindings = nullptr; + if (void *result = type_c2p_fast.lookup(type); nb_is_foreign(result)) + foreign_bindings = nb_get_foreign(result); + else if (auto *t = (type_data *) result) + foreign_bindings = nb_load_acquire(t->foreign_bindings); + if (!foreign_bindings) + return nullptr; + + if (NB_LIKELY(!nb_is_seq(foreign_bindings))) { + // Single foreign binding - check that it's not our own + auto *binding = (pymb_binding *) foreign_bindings; + if (binding->framework != internals_->foreign_self && + pymb_try_ref_binding(binding)) { +#if defined(NB_FREE_THREADED) + // attempt() might execute Python code; drop the map mutex + // to avoid a deadlock + per_thread_guard = {}; +#endif + void *result = attempt(closure, binding); + pymb_unref_binding(binding); + return result; + } + return nullptr; + } + + // Multiple foreign bindings - try all except our own. +#if !defined(NB_FREE_THREADED) + nb_foreign_seq *current = nb_get_seq(foreign_bindings); + while (current) { + auto *binding = current->value; + if (binding->framework != internals_->foreign_self && + pymb_try_ref_binding(binding)) { + void *result = attempt(closure, binding); + pymb_unref_binding(binding); + if (result) + return result; + } + current = current->next; + } + return nullptr; +#else + // In free-threaded mode, this is tricky: we need to drop the + // per_thread_guard before calling attempt(), but once we do so, + // any of these bindings that might be in the middle of getting deleted + // can be concurrently removed from the linked list, which would interfere + // with our iteration. Copy the binding pointers out of the list to avoid + // this problem. + + // Count the number of foreign bindings we might see + size_t len = 0; + nb_foreign_seq *current = nb_get_seq(foreign_bindings); + while (current) { + ++len; + current = nb_load_acquire(current->next); + } + + // Allocate temporary storage for that many pointers + pymb_binding **scratch = + (pymb_binding **) alloca(len * sizeof(pymb_binding*)); + pymb_binding **scratch_tail = scratch; + + // Iterate again, taking out strong references and saving pointers to + // our scratch storage. Concurrency notes: + // - If bindings are removed while we iterate, we may either visit them + // (and do nothing since try_ref returns false) or skip them. Binding + // removal will lock all c2p_fast maps in between when it modifies the + // linked list and when it deallocates the removed node, so we're safe + // from concurrent deallocation as long as we hold the lock. + // - If bindings are added at the front of the list while we iterate, + // they don't impact us since we're working with a local copy of the + // head ptr `foreign_bindings`. + // - If bindings are added at the rear of the list while we iterate, + // we may either include them (if we didn't use some of the scratch + // slots we allocated previously) or not, but we'll always decref + // everything we incref. + current = nb_get_seq(foreign_bindings); + while (current && scratch != scratch_tail + len) { + auto *binding = current->value; + if (binding->framework != internals_->foreign_self && + pymb_try_ref_binding(binding)) + *scratch_tail++ = binding; + current = nb_load_acquire(current->next); + } + + // Drop the lock and proceed using only our saved binding pointers. + // Since we obtained strong references to them, there is no remaining + // concurrent-destruction hazard. + per_thread_guard = {}; + void *result = nullptr; + while (scratch != scratch_tail) { + if (!result) + result = attempt(closure, *scratch); + pymb_unref_binding(*scratch); + ++scratch; + } + return result; +#endif +} + +NAMESPACE_END(detail) +NAMESPACE_END(NB_NAMESPACE) + +#endif /* !defined(NB_DISABLE_FOREIGN) */ diff --git a/src/nb_func.cpp b/src/nb_func.cpp index 915b2fca8..2c60c0166 100644 --- a/src/nb_func.cpp +++ b/src/nb_func.cpp @@ -150,7 +150,7 @@ static arg_data method_args[2] = { { nullptr, nullptr, nullptr, nullptr, 0 } }; -static bool set_builtin_exception_status(builtin_exception &e) { +bool set_builtin_exception_status(builtin_exception &e) { PyObject *o; switch (e.type()) { @@ -583,8 +583,8 @@ static NB_NOINLINE PyObject *nb_func_error_noconvert(PyObject *self, static NB_NOINLINE void nb_func_convert_cpp_exception() noexcept { std::exception_ptr e = std::current_exception(); - for (nb_translator_seq *cur = &internals->translators; cur; - cur = cur->next) { + for (nb_translator_seq *cur = internals->translators.load_acquire(); + cur; cur = cur->next.load_acquire()) { try { // Try exception translator & forward payload cur->translator(e, cur->payload); @@ -1306,13 +1306,28 @@ static uint32_t nb_func_render_signature(const func_data *f, if (!(is_method && arg_index == 0)) { bool found = false; auto it = internals_->type_c2p_slow.find(*descr_type); - if (it != internals_->type_c2p_slow.end()) { - handle th((PyObject *) it->second->type_py); - buf.put_dstr((borrow(th.attr("__module__"))).c_str()); - buf.put('.'); - buf.put_dstr((borrow(th.attr("__qualname__"))).c_str()); - found = true; + object ty; +#if !defined(NB_DISABLE_FOREIGN) + if (nb_is_foreign(it->second)) { + void *bindings = nb_get_foreign(it->second); + pymb_binding *binding = + nb_is_seq(bindings) ? + nb_get_seq(bindings)->value : + (pymb_binding *) bindings; + if (pymb_try_ref_binding(binding)) { + ty = borrow(binding->pytype); + pymb_unref_binding(binding); + } + } else +#endif + ty = borrow(((type_data *) it->second)->type_py); + if (ty) { + buf.put_dstr((borrow(ty.attr("__module__"))).c_str()); + buf.put('.'); + buf.put_dstr((borrow(ty.attr("__qualname__"))).c_str()); + found = true; + } } if (!found) { if (nb_signature_mode) diff --git a/src/nb_internals.cpp b/src/nb_internals.cpp index 4adf53004..678919c16 100644 --- a/src/nb_internals.cpp +++ b/src/nb_internals.cpp @@ -219,9 +219,9 @@ static void internals_cleanup() { for (size_t i = 0; i < p->shard_count && ctr < 20; ++i) { for (auto [k, v]: p->shards[i].inst_c2p) { if (NB_UNLIKELY(nb_is_seq(v))) { - nb_inst_seq* seq = nb_get_seq(v); + nb_inst_seq* seq = nb_get_seq(v); for(; seq != nullptr && ctr < 20; seq = seq->next) { - print_leak(k, seq->inst); + print_leak(k, seq->value); INC_CTR; } } else { @@ -249,12 +249,20 @@ static void internals_cleanup() { #endif if (!p->type_c2p_slow.empty()) { - if (print_leak_warnings) { - fprintf(stderr, "nanobind: leaked %zu types!\n", - p->type_c2p_slow.size()); + size_t type_leaks = 0; + for (const auto &kv : p->type_c2p_slow) { + if (!nb_is_foreign(kv.second)) + ++type_leaks; + } + + if (type_leaks && print_leak_warnings) { + fprintf(stderr, "nanobind: leaked %zu types!\n", type_leaks); int ctr = 0; for (const auto &kv : p->type_c2p_slow) { - fprintf(stderr, " - leaked type \"%s\"\n", kv.second->name); + if (nb_is_foreign(kv.second)) + continue; + fprintf(stderr, " - leaked type \"%s\"\n", + ((type_data *) kv.second)->name); INC_CTR; if (ctr == 10) { fprintf(stderr, " - ... skipped remainder\n"); @@ -262,7 +270,7 @@ static void internals_cleanup() { } } } - leak = true; + leak |= (type_leaks > 0); } if (!p->funcs.empty()) { @@ -284,13 +292,29 @@ static void internals_cleanup() { } if (!leak) { - nb_translator_seq* t = p->translators.next; + nb_translator_seq* t = p->translators.load_acquire(); while (t) { - nb_translator_seq *next = t->next; + nb_translator_seq *next = t->next.load_acquire(); delete t; t = next; } + if (p->foreign_self) { + pymb_list_unlink(&p->foreign_self->hook); + delete p->foreign_self; + } + + for (auto &kv : p->types_in_c2p_fast) { + if (!kv.second) + continue; + nb_alias_seq *node = (nb_alias_seq *) kv.second; + while (node) { + nb_alias_seq *next = node->next; + delete node; + node = next; + } + } + #if defined(NB_FREE_THREADED) // This code won't run for now but is kept here for a time when // immortalization isn't needed anymore. @@ -426,7 +450,6 @@ NB_NOINLINE void init(const char *name) { Py_DECREF(dummy); #endif - p->translators = { default_exception_translator, nullptr, nullptr }; is_alive_value = true; is_alive_ptr = &is_alive_value; p->is_alive_ptr = is_alive_ptr; @@ -480,6 +503,7 @@ NB_NOINLINE void init(const char *name) { Py_DECREF(capsule); Py_DECREF(key); internals = p; + register_exception_translator(default_exception_translator, nullptr, false); } #if defined(NB_COMPACT_ASSERTIONS) @@ -493,5 +517,24 @@ NB_NOINLINE void fail_unspecified() noexcept { } #endif +#if defined(NB_FREE_THREADED) +nb_type_map_per_thread::nb_type_map_per_thread(nb_internals &internals_) + : internals(internals_) { + lock_internals l{&internals}; + next = internals.type_c2p_per_thread_head; + internals.type_c2p_per_thread_head = this; +} +nb_type_map_per_thread::~nb_type_map_per_thread() { + lock_internals l{&internals}; + nb_type_map_per_thread** pcurr = &internals.type_c2p_per_thread_head; + while (*pcurr) { + if (*pcurr == this) + *pcurr = next; + else + pcurr = &((*pcurr)->next); + } +} +#endif + NAMESPACE_END(detail) NAMESPACE_END(NB_NAMESPACE) diff --git a/src/nb_internals.h b/src/nb_internals.h index ca79920dd..30100df02 100644 --- a/src/nb_internals.h +++ b/src/nb_internals.h @@ -20,6 +20,7 @@ #include #include #include "hash.h" +#include "pymetabind.h" #if TSL_RH_VERSION_MAJOR != 1 || TSL_RH_VERSION_MINOR < 3 # error nanobind depends on tsl::robin_map, in particular version >= 1.3.0, <2.0.0 @@ -149,19 +150,64 @@ template class py_allocator { void deallocate(T *p, size_type /*n*/) noexcept { PyMem_Free(p); } }; -// Linked list of instances with the same pointer address. Usually just 1. -struct nb_inst_seq { - PyObject *inst; - nb_inst_seq *next; +/// nanobind maintains several maps where there is usually a single entry, +/// but sometimes a list of them. To avoid allocating linked list nodes in +/// the common case, the map value is a pointer whose lowest bit is a type +/// discriminant: 0 if pointing to a single T, and 1 if pointing to a nb_seq. +template +struct nb_seq { + T *value; + nb_seq *next; }; +using nb_inst_seq = nb_seq; +using nb_alias_seq = nb_seq; +using nb_foreign_seq = nb_seq; -// Linked list of type aliases when there are multiple shared libraries with duplicate RTTI data -struct nb_alias_chain { - const std::type_info *value; - nb_alias_chain *next; -}; +/// Convenience functions to deal with such encoded pointers + +/// Does this entry store a linked list of instances? +NB_INLINE bool nb_is_seq(void *p) { return ((uintptr_t) p) & 1; } + +/// Tag a nb_seq* pointer as such +template +NB_INLINE void* nb_mark_seq(nb_seq *p) { return (void *) (((uintptr_t) p) | 1); } + +/// Retrieve the nb_seq* pointer from an 'inst_c2p' value, assuming nb_is_seq(p) +template +NB_INLINE nb_seq* nb_get_seq(void *p) { return (nb_seq *) (((uintptr_t) p) ^ 1); } + +template +nb_seq* nb_ensure_seq(void **p) { + if (nb_is_seq(*p)) + return nb_get_seq(*p); + nb_seq *node = (nb_seq *) PyMem_Malloc(sizeof(nb_seq)); + node->value = (T *) *p; + node->next = nullptr; + *p = nb_mark_seq(node); + return node; +} + +/// Analogous convenience functions for nanobind vs foreign type disambiguation +/// in type_c2p_* map values. These store one of type_data*, pymb_binding* | 2, +/// or nb_foreign_seq* | 3. So, if !nb_is_foreign(p), cast to type_data* +/// directly; otherwise use either nb_get_seq(nb_get_foreign(p)) +/// or (pymb_binding*) nb_get_foreign(p) depending on the value of +/// nb_is_seq(nb_get_foreign(p)). + +#if defined(NB_DISABLE_FOREIGN) +NB_INLINE bool nb_is_foreign(void *) { return false; } +#else +NB_INLINE bool nb_is_foreign(void *p) { return ((uintptr_t) p) & 2; } +NB_INLINE void* nb_mark_foreign(void *p) { return (void *) (((uintptr_t) p) | 2); } +NB_INLINE void* nb_get_foreign(void *p) { return (void *) (((uintptr_t) p) ^ 2); } +static_assert(alignof(type_data) >= 4 && alignof(pymb_binding) >= 4 && + alignof(nb_foreign_seq) >= 4, + "not enough alignment bits for discriminant scheme"); +#endif -// Weak reference list. Usually, there is just one entry +// Entry in a list of keep_alive weak references. This does not use the +// low-order bit discriminator because the payload is not pointer-sized; +// even if an object has a single weak reference, it will use the seq. struct nb_weakref_seq { void (*callback)(void *) noexcept; void *payload; @@ -181,30 +227,182 @@ struct std_typeinfo_eq { } }; -using nb_type_map_fast = tsl::robin_map; -using nb_type_map_slow = tsl::robin_map; /// A simple pointer-to-pointer map that is reused a few times below (even if /// not 100% ideal) to avoid template code generation bloat. -using nb_ptr_map = tsl::robin_map; +using nb_ptr_map = tsl::robin_map; + +/// A map from std::type_info to type data pointer, where lookups use +/// pointer comparisons. The values stored here can be NULL (if a negative +/// lookup has been cached) or else point to any of three types, discriminated +/// using the two lowest-order bits of the pointer; see TYPE MAPPING above. +struct nb_type_map_fast { + /// Look up a type. If not present in the map, add it with value `dflt`; + /// then return a reference to the stored value, which the caller may + /// modify. + void*& lookup_or_set(const std::type_info *ti, void *dflt) { + return data.try_emplace((void *) ti, dflt).first.value(); + } -/// Convenience functions to deal with the pointer encoding in 'internals.inst_c2p' + /// Look up a type. Return its associated value, or nullptr if not present. + /// This method can't distinguish cached negative lookups from entries + /// that aren't in the map. + void* lookup(const std::type_info *ti) { + auto it = data.find((void *) ti); + return it == data.end() ? nullptr : it->second; + } -/// Does this entry store a linked list of instances? -NB_INLINE bool nb_is_seq(void *p) { return ((uintptr_t) p) & 1; } + /// Override the stored value for a type, if present. Return true if + /// anything was changed. + bool update(const std::type_info* ti, void *value) { + auto it = data.find((void *) ti); + if (it != data.end()) { + it.value() = value; + return true; + } + return false; + } -/// Tag a nb_inst_seq* pointer as such -NB_INLINE void* nb_mark_seq(void *p) { return (void *) (((uintptr_t) p) | 1); } + private: + // Use a generic ptr->ptr map to avoid needing another instantiation of + // robin_map. Keys are const std::type_info*. See TYPE MAPPING above for + // the interpretation of the values. + nb_ptr_map data; +}; -/// Retrieve the nb_inst_seq* pointer from an 'inst_c2p' value -NB_INLINE nb_inst_seq* nb_get_seq(void *p) { return (nb_inst_seq *) (((uintptr_t) p) ^ 1); } +#if defined(NB_FREE_THREADED) +struct nb_internals; -struct nb_translator_seq { - exception_translator translator; - void *payload; - nb_translator_seq *next = nullptr; +/** + * Wrapper for nb_type_map_fast in free-threaded mode. Each extension module + * in a nanobind domain has its own instance of this in thread-local storage + * for each thread that has used nanobind bindings exposed by that extension + * module. When the slow map is modified in a way that would invalidate the + * fast map (removing a cached entry or adding an entry for which a negative + * lookup has been cached), the linked list is used to update all the caches. + * Outside of such actions, which occur infrequently, this is a thread-local + * structure so the mutex accesses are never contended. + */ +struct nb_type_map_per_thread { + explicit nb_type_map_per_thread(nb_internals &internals_); + ~nb_type_map_per_thread(); + + struct guard { + guard() = default; + guard(guard&& other) noexcept : parent(other.parent) { + other.parent = nullptr; + } + guard& operator=(guard other) noexcept { + std::swap(parent, other.parent); + return *this; + } + ~guard() { + if (parent) + PyMutex_Unlock(&parent->mutex); + } + + nb_type_map_fast& operator*() const { return parent->map; } + nb_type_map_fast* operator->() const { return &parent->map; } + + private: + friend nb_type_map_per_thread; + explicit guard(nb_type_map_per_thread &parent_) : parent(&parent_) { + PyMutex_Lock(&parent->mutex); + } + nb_type_map_per_thread *parent = nullptr; + }; + guard lock() { return guard{*this}; } + + // Mutex protecting accesses to `map` + PyMutex mutex{}; + nb_type_map_fast map; + nb_internals &internals; + + // In order to access or modify `next`, you must hold the nb_internals mutex + // (this->mutex is not needed for iteration) + nb_type_map_per_thread *next = nullptr; }; +#endif #if defined(NB_FREE_THREADED) # define NB_SHARD_ALIGNMENT alignas(64) @@ -223,8 +421,8 @@ struct NB_SHARD_ALIGNMENT nb_shard { * * This associative data structure maps a C++ instance pointer onto its * associated PyObject* (if bit 0 of the map value is zero) or a linked - * list of type `nb_inst_seq*` (if bit 0 is set---it must be cleared before - * interpreting the pointer in this case). + * list of type `nb_inst_seq*` (if bit 0 is set---it must be cleared + * before interpreting the pointer in this case). * * The latter case occurs when several distinct Python objects reference * the same memory address (e.g. a struct and its first member). @@ -239,7 +437,6 @@ struct NB_SHARD_ALIGNMENT nb_shard { #endif }; - /** * Wraps a std::atomic if free-threading is enabled, otherwise a raw value. */ @@ -249,9 +446,12 @@ struct nb_maybe_atomic { nb_maybe_atomic(T v) : value(v) {} std::atomic value; - T load_acquire() { return value.load(std::memory_order_acquire); } - T load_relaxed() { return value.load(std::memory_order_relaxed); } - void store_release(T w) { value.store(w, std::memory_order_release); } + NB_INLINE T load_acquire() { return value.load(std::memory_order_acquire); } + NB_INLINE T load_relaxed() { return value.load(std::memory_order_relaxed); } + NB_INLINE void store_release(T w) { value.store(w, std::memory_order_release); } + NB_INLINE bool compare_exchange_weak(T& expected, T desired) { + return value.compare_exchange_weak(expected, desired); + } }; #else template @@ -259,12 +459,56 @@ struct nb_maybe_atomic { nb_maybe_atomic(T v) : value(v) {} T value; - T load_acquire() { return value; } - T load_relaxed() { return value; } - void store_release(T w) { value = w; } + NB_INLINE T load_acquire() { return value; } + NB_INLINE T load_relaxed() { return value; } + NB_INLINE void store_release(T w) { value = w; } + NB_INLINE bool compare_exchange_weak(T& expected, T desired) { + check(value == expected, "compare-exchange would deadlock"); + value = desired; + return true; + } }; #endif +/** + * Access a non-std::atomic using atomics if we're free-threading -- + * for type_data::foreign_bindings (so we don't have to #include + * in nanobind.h) and nb_foreign_seq::next (so that nb_seq can be generic) + */ +#if !defined(NB_FREE_THREADED) +template NB_INLINE T nb_load_acquire(T& loc) { return loc; } +template NB_INLINE void nb_store_release(T& loc, T val) { loc = val; } +#elif __cplusplus >= 202002L +// Use std::atomic_ref if available +template +NB_INLINE T nb_load_acquire(T& loc) { + return std::atomic_ref(loc).load(std::memory_order_acquire); +} +template +NB_INLINE void nb_store_release(T& loc, T val) { + return std::atomic_ref(loc).store(val, std::memory_order_release); +} +#else +// Fallback to type punning if not +template +NB_INLINE T nb_load_acquire(T& loc) { + return std::atomic_load_explicit((std::atomic *) &loc, + std::memory_order_acquire); +} +template +NB_INLINE void nb_store_release(T& loc, T val) { + return std::atomic_store_explicit((std::atomic *) &loc, val, + std::memory_order_release); +} +#endif + +// Entry in a list of exception translators +struct nb_translator_seq { + exception_translator translator; + void *payload; + nb_maybe_atomic next = nullptr; +}; + /** * `nb_internals` is the central data structure storing information related to * function/type bindings and instances. Separate nanobind extensions within the @@ -312,27 +556,39 @@ struct nb_maybe_atomic { * potentially hot and shares the sharding scheme of `inst_c2p`. * * - `type_c2p_slow`: This is the ground-truth source of the `std::type_info` - * to `type_info *` mapping. Unrelated to free-threading, lookups into this + * to type data mapping. Unrelated to free-threading, lookups into this * data struture are generally costly because they use a string comparison on * some platforms. Because it is only used as a fallback for 'type_c2p_fast', * protecting this member via the global `mutex` is sufficient. * - * - `type_c2p_fast`: this data structure is *hot* and mostly read. It maps - * `std::type_info` to `type_info *` but uses pointer-based comparisons. - * The implementation depends on the Python build. + * - `types_in_c2p_fast`: Used only when accessing or updating `type_c2p_slow`, so + * protecting it with the global `mutex` adds no additional overhead. + * + * - `foreign_registry`, `foreign_self`: created only once on demand, + * protected by `mutex`; often OK to read without locking since they never + * change once set * - * - `translators`: This is an append-to-front-only singly linked list traversed - * while raising exceptions. The main concern is losing elements during - * concurrent append operations. We assume that this data structure is only - * written during module initialization and don't use locking. + * - `type_c2p_fast`: this data structure is *hot* and mostly read. It serves + * as a cache of `type_c2p_slow`, mapping `std::type_info` to type data using + * pointer-based comparisons. On free-threaded builds, each thread gets its + * own mostly-local instance inside `nb_type_data_per_thread`, which is + * protected by an internal mutex in order to safely handle the rare need for + * cache invalidations. The head of the linked list of these instances, + * `type_c2p_per_thread_head`, is protected by `mutex`; it is only accessed + * rarely (when a new thread first uses nanobind, when a thread exits, and + * when a type is created or destroyed that has previously been cached). + * + * - `translators`: This is a singly linked list traversed while raising + * exceptions, from which no element is ever removed. The rare insertions use + * compare-and-swap on the head or prev->next pointer. * * - `funcs`: data structure for function leak tracking. Not used in - * free-threaded mode . + * free-threaded mode. * - * - `print_leak_warnings`, `print_implicit_cast_warnings`: simple boolean - * flags. No protection against concurrent conflicting updates. + * - `print_leak_warnings`, `print_implicit_cast_warnings`, + * `foreign_export`, `foreign_import`: simple configuration flags. + * No protection against concurrent conflicting updates. */ - struct nb_internals { /// Internal nanobind module PyObject *nb_module; @@ -375,22 +631,36 @@ struct nb_internals { inline nb_shard &shard(void *) { return shards[0]; } #endif + /* See TYPE MAPPING above for much more detail on the interplay of + type_c2p_fast, type_c2p_slow, and types_in_c2p_fast */ + #if !defined(NB_FREE_THREADED) /// C++ -> Python type map -- fast version based on std::type_info pointer equality nb_type_map_fast type_c2p_fast; +#else + /// Head of the list of per-thread fast C++ -> Python type maps + nb_type_map_per_thread *type_c2p_per_thread_head = nullptr; #endif /// C++ -> Python type map -- slow fallback version based on hashed strings nb_type_map_slow type_c2p_slow; + /// Each std::type_info that is a key in any `nb_type_map_fast` is + /// equivalent to some key in this map. If (by pointer equality) there is + /// only one such std::type_info, the value is null; otherwise, the value + /// is a `nb_alias_seq*` that heads a list of types that are + /// equivalent to the key but have distinct `std::type_info` pointers. + nb_type_map_slow types_in_c2p_fast; + #if !defined(NB_FREE_THREADED) /// nb_func/meth instance map for leak reporting (used as set, the value is unused) /// In free-threaded mode, functions are immortal and don't require this data structure. nb_ptr_map funcs; #endif - /// Registered C++ -> Python exception translators - nb_translator_seq translators; + /// Registered C++ -> Python exception translators. The default exception + /// translator is the last one in this list. + nb_maybe_atomic translators = nullptr; /// Should nanobind print leak warnings on exit? bool print_leak_warnings = true; @@ -398,6 +668,33 @@ struct nb_internals { /// Should nanobind print warnings after implicit cast failures? bool print_implicit_cast_warnings = true; + /// Should this nanobind domain advertise all of its own types to other + /// binding frameworks (including other nanobind domains) for use by other + /// extension modules loaded in this interpreter? Even if this is disabled, + /// you can export individual types using nb::export_type_to_foreign(). + bool foreign_export_all = false; + + /// Should this nanobind domain make use of all C++ types advertised by + /// other binding frameworks (including other nanobind domains) from other + /// extension modules loaded in this interpreter? Even if this is disabled, + /// you can import individual types using nb::import_foreign_type(). + bool foreign_import_all = false; + + /// Have there ever been any foreign types in `type_c2p_slow`? If not, + /// we can skip some logic in nb_type_get/put. + bool foreign_imported_any = false; + + /// Pointer to pymetabind registry, if enabled + pymb_registry *foreign_registry = nullptr; + + /// Pointer to our own framework object in pymetabind, if enabled + pymb_framework *foreign_self = nullptr; + + /// Map from pymb_binding* to std::type_info*, reflecting types exported by + /// (typically) non-C++ extension modules that have been associated with + /// C++ types via nb::import_foreign_type() + nb_ptr_map foreign_manual_imports; + /// Pointer to a boolean that denotes if nanobind is fully initialized. bool *is_alive_ptr = nullptr; @@ -437,8 +734,32 @@ extern PyObject *inst_new_ext(PyTypeObject *tp, void *value); extern PyObject *inst_new_int(PyTypeObject *tp, PyObject *args, PyObject *kwds); extern PyTypeObject *nb_static_property_tp() noexcept; extern type_data *nb_type_c2p(nb_internals *internals, - const std::type_info *type); + const std::type_info *type, + bool *has_foreign = nullptr); +extern bool nb_type_register(type_data *t, type_data **conflict) noexcept; extern void nb_type_unregister(type_data *t) noexcept; +#if defined(NB_FREE_THREADED) +extern nb_type_map_per_thread::guard nb_type_lock_c2p_fast( + nb_internals *internals_) noexcept; +#endif +extern void nb_type_update_c2p_fast(const std::type_info *type, + void *value) noexcept; + +#if !defined(NB_DISABLE_FOREIGN) +extern void *nb_type_try_foreign(nb_internals *internals_, + const std::type_info *type, + void* (*attempt)(void *closure, + pymb_binding *binding), + void *closure); +extern void nb_type_import_impl(PyObject *pytype, + const std::type_info *cpptype); +extern void nb_type_export_impl(type_data *td); +extern void nb_type_enable_import_all(); +extern void nb_type_enable_export_all(); +#endif + +extern bool set_builtin_exception_status(builtin_exception &e); +extern void default_exception_translator(const std::exception_ptr &, void *); extern PyObject *call_one_arg(PyObject *fn, PyObject *arg) noexcept; diff --git a/src/nb_type.cpp b/src/nb_type.cpp index f60083d77..b84585651 100644 --- a/src/nb_type.cpp +++ b/src/nb_type.cpp @@ -185,22 +185,11 @@ static void inst_register(PyObject *inst, void *value) noexcept { auto [it, success] = shard.inst_c2p.try_emplace(value, inst); if (NB_UNLIKELY(!success)) { - void *entry = it->second; - // Potentially convert the map value into linked list format - if (!nb_is_seq(entry)) { - nb_inst_seq *first = (nb_inst_seq *) PyMem_Malloc(sizeof(nb_inst_seq)); - check(first, "nanobind::detail::inst_new_ext(): list element " - "allocation failed!"); - first->inst = (PyObject *) entry; - first->next = nullptr; - entry = it.value() = nb_mark_seq(first); - } - - nb_inst_seq *seq = nb_get_seq(entry); + nb_inst_seq *seq = nb_ensure_seq(&it.value()); while (true) { // The following should never happen - check(inst != seq->inst, "nanobind::detail::inst_new_ext(): duplicate instance!"); + check(inst != seq->value, "nanobind::detail::inst_new_ext(): duplicate instance!"); if (!seq->next) break; @@ -211,7 +200,7 @@ static void inst_register(PyObject *inst, void *value) noexcept { check(next, "nanobind::detail::inst_new_ext(): list element allocation failed!"); - next->inst = (PyObject *) inst; + next->value = inst; next->next = nullptr; seq->next = next; } @@ -295,11 +284,11 @@ static void inst_dealloc(PyObject *self) { inst_c2p.erase_fast(it); } else if (nb_is_seq(entry)) { // Multiple objects are associated with this address. Find the right one! - nb_inst_seq *seq = nb_get_seq(entry), + nb_inst_seq *seq = nb_get_seq(entry), *pred = nullptr; do { - if ((nb_inst *) seq->inst == inst) { + if ((nb_inst *) seq->value == inst) { found = true; if (pred) { @@ -346,94 +335,235 @@ static void inst_dealloc(PyObject *self) { Py_DECREF(tp); } +#if defined(NB_FREE_THREADED) +nb_type_map_per_thread::guard nb_type_lock_c2p_fast(nb_internals *internals_) + noexcept { + thread_local nb_type_map_per_thread type_c2p_per_thread{*internals_}; + return type_c2p_per_thread.lock(); +} +#endif + +static void nb_type_c2p_fill_from_slow(nb_internals *internals_, + void *&fast_value, + const std::type_info *type) { + // Cache miss. Fetch the true value from the slow map. + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; + nb_type_map_slow::iterator it_slow = type_c2p_slow.find(type); + fast_value = (it_slow != type_c2p_slow.end() ? it_slow->second : nullptr); + + // Maintain a linked list to clean up 'type_c2p_fast' when the type + // expires (see nb_type_unregister). + auto [it_alias, inserted] = + internals_->types_in_c2p_fast.try_emplace(type, nullptr); + if (!inserted) { + // Check whether `type` is an alias we haven't seen yet. + bool seen = (type == it_alias->first); + nb_alias_seq *prev = nullptr; + nb_alias_seq *node = (nb_alias_seq *) it_alias->second; + while (node && !seen) { + seen |= (type == node->value); + prev = node; + node = node->next; + } + if (!seen) { + // Got a new one -- add it. NB: use non-Python allocator for these + // since we won't free them until internals_cleanup(), which is + // after interpreter finalization + node = (nb_alias_seq *) malloc(sizeof(nb_alias_seq)); + check(node, "Could not allocate alias chain entry!"); + node->value = type; + node->next = nullptr; + if (prev) + prev->next = node; + else + it_alias.value() = node; + } + } +} type_data *nb_type_c2p(nb_internals *internals_, - const std::type_info *type) { + const std::type_info *type, + bool *has_foreign) { #if defined(NB_FREE_THREADED) - thread_local nb_type_map_fast type_c2p_fast; + auto per_thread_guard = nb_type_lock_c2p_fast(internals_); + nb_type_map_fast &type_c2p_fast = *per_thread_guard; #else nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; #endif + void *const sentinel = (void *) (uintptr_t) 1; + void *&slot = type_c2p_fast.lookup_or_set(type, sentinel); + void *result = slot; - nb_type_map_fast::iterator it_fast = type_c2p_fast.find(type); - if (it_fast != type_c2p_fast.end()) - return it_fast->second; + if (NB_UNLIKELY(result == sentinel)) { + // Cache miss. Fetch the true value from the slow map. +#if defined(NB_FREE_THREADED) + // Accessing the slow map requires locking internals. We must first + // unlock the local mutex in order to maintain consistent lock ordering + // against concurrent updates from `nb_type_update_c2p_fast`. Since all + // operations on the fast type map from other threads are in-place value + // updates (insertions only come from our thread and deletions never + // occur), `slot` won't be invalidated during the brief period where + // we hold no locks. + per_thread_guard = {}; + + // While `slot` still will point to the correct place, it's possible + // that its value changes here if another thread modifies the c2p_slow + // mapping for this type. If it does so, then `type` must have already + // been in the alias map, so there's nothing further to do. - lock_internals guard(internals_); - nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; - nb_type_map_slow::iterator it_slow = type_c2p_slow.find(type); - if (it_slow != type_c2p_slow.end()) { - type_data *d = it_slow->second; - -#if !defined(NB_FREE_THREADED) - // Maintain a linked list to clean up 'type_c2p_fast' when the type - // expires (see nb_type_unregister). In free-threaded mode, we leak - // these entries until the thread destructs. - nb_alias_chain *chain = - (nb_alias_chain *) PyMem_Malloc(sizeof(nb_alias_chain)); - check(chain, "Could not allocate nb_alias_chain entry!"); - chain->next = d->alias_chain; - chain->value = type; - d->alias_chain = chain; + lock_internals guard(internals_); + if (NB_LIKELY(slot == sentinel)) #endif + nb_type_c2p_fill_from_slow(internals_, slot, type); + result = slot; - type_c2p_fast[type] = d; - return d; + // NB: after we drop `guard` at the end of this block, we'll hold no + // locks, so the type_data we're looking up could be concurrently + // deleted on free-threaded builds if nanobind didn't immortalize types. + // (Since we return it without locking regardless, extending the + // locking here wouldn't help on its own.) } - return nullptr; + auto *t = (type_data *) result; + +#if !defined(NB_DISABLE_FOREIGN) + if (nb_is_foreign(result)) { + if (has_foreign) + *has_foreign = true; + return nullptr; + } + + if (has_foreign) { + // t->foreign_bindings is the list of registered bindings for this C++ + // type, including potentially one that we exported. Report has_foreign + // only if there's more than one or the single one isn't ours. + void *foreign_bindings = t ? nb_load_acquire(t->foreign_bindings) + : nullptr; + *has_foreign = t && foreign_bindings && + (nb_is_seq(foreign_bindings) || + ((pymb_binding *) foreign_bindings)->framework != + internals_->foreign_self); + } +#else + (void) has_foreign; +#endif + + return t; } -void nb_type_unregister(type_data *t) noexcept { +static bool nb_type_update_cache(nb_type_map_fast &cache, + const std::type_info *type, + nb_alias_seq *more_types, + void *value) { + bool found = cache.update(type, value); + while (more_types) { + found |= cache.update(more_types->value, value); + more_types = more_types->next; + } + return found; +} + +void nb_type_update_c2p_fast(const std::type_info *type, void *value) noexcept { + // internals must be locked by the caller + nb_internals *internals_ = internals; + auto it_alias = internals_->types_in_c2p_fast.find(type); + if (it_alias != internals_->types_in_c2p_fast.end()) { + bool found = false; +#if defined(NB_FREE_THREADED) + for (nb_type_map_per_thread *cache = + internals_->type_c2p_per_thread_head; + cache; cache = cache->next) { + found |= nb_type_update_cache(*cache->lock(), it_alias->first, + (nb_alias_seq *) it_alias->second, + value); + } +#else + found = nb_type_update_cache(internals_->type_c2p_fast, it_alias->first, + (nb_alias_seq *) it_alias->second, value); +#endif + check(found, "nanobind::detail::nb_type_update_c2p_fast(\"%s\"): " + "types_in_c2p_fast and type_c2p_fast are inconsistent", + type_name(type)); + } +} + +bool nb_type_register(type_data *t, type_data **conflict) noexcept { nb_internals *internals_ = internals; nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; lock_internals guard(internals_); - size_t n_del_slow = type_c2p_slow.erase(t->type); - -#if defined(NB_FREE_THREADED) - // In free-threaded mode, stale type information remains in the - // 'type_c2p_fast' TLS. This data structure is eventually deallocated - // when the thread terminates. - // - // In principle, this is dangerous because the user could delete a type - // binding from a module at runtime, causing the associated - // Python type object to be freed. If a function then attempts to return - // a value with such a de-registered type, nanobind should raise an - // exception, which requires knowing that the entry in 'type_c2p_fast' - // has become invalid in the meantime. - // - // Right now, this problem is avoided because we immortalize type objects in - // ``nb_type_new()`` and ``enum_create()``. However, we may not always - // want to stick with immortalization, which is just a workaround. - // - // In the future, a global version counter modified with acquire/release - // semantics (see https://github.com/wjakob/nanobind/pull/695#discussion_r1761600010) - // might prove to be a similarly efficient but more general solution. - bool fail = n_del_slow != 1; -#else - nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; - size_t n_del_fast = type_c2p_fast.erase(t->type); - - bool fail = n_del_fast != 1 || n_del_slow != 1; - if (!fail) { - nb_alias_chain *cur = t->alias_chain; - while (cur) { - nb_alias_chain *next = cur->next; - n_del_fast = type_c2p_fast.erase(cur->value); - if (n_del_fast != 1) { - fail = true; - break; - } - PyMem_Free(cur); - cur = next; + auto [it_slow, inserted] = type_c2p_slow.try_emplace(t->type, t); + if (!inserted) { +#if !defined(NB_DISABLE_FOREIGN) + if (nb_is_foreign(it_slow->second)) { + nb_store_release(t->foreign_bindings, + nb_get_foreign(it_slow->second)); + it_slow.value() = t; + } else +#endif + { + *conflict = (type_data *) it_slow->second; + return false; // already registered } } + nb_type_update_c2p_fast(t->type, t); +#if !defined(NB_DISABLE_FOREIGN) + if (internals_->foreign_export_all) + nb_type_export_impl(t); #endif + return true; +} + +void nb_type_unregister(type_data *t) noexcept { + nb_internals *internals_ = internals; + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; - check(!fail, + lock_internals guard(internals_); + auto it_slow = type_c2p_slow.find(t->type); + check(it_slow != type_c2p_slow.end() && it_slow->second == t, "nanobind::detail::nb_type_unregister(\"%s\"): could not " "find type!", t->name); +#if defined(NB_DISABLE_FOREIGN) + type_c2p_slow.erase(it_slow); + nb_type_update_c2p_fast(t->type, nullptr); +#else + void *foreign_bindings = nb_load_acquire(t->foreign_bindings); + pymb_binding *binding_to_free = nullptr; + nb_foreign_seq *node_to_free = nullptr; + + if (nb_is_seq(foreign_bindings)) { + nb_foreign_seq *node = nb_get_seq(foreign_bindings); + if (node->value->framework == internals_->foreign_self) { + binding_to_free = node->value; + node_to_free = node; + foreign_bindings = nb_mark_seq(node->next); + } + } else if (auto *binding = (pymb_binding *) foreign_bindings; + binding && binding->framework == internals_->foreign_self) { + binding_to_free = binding; + foreign_bindings = nullptr; + } + + void *new_value; + if (foreign_bindings) { + new_value = nb_mark_foreign(foreign_bindings); + it_slow.value() = new_value; + } else { + new_value = nullptr; + type_c2p_slow.erase(it_slow); + } + nb_type_update_c2p_fast(t->type, new_value); + + // Don't actually free the binding until we've updated the c2p fast map. + // Other threads may concurrently be in nb_type_try_foreign(); see + // comments there for more details on the synchronization here. + if (binding_to_free) { + pymb_remove_binding(internals_->foreign_registry, binding_to_free); + free((char *) binding_to_free->source_name); + PyMem_Free(binding_to_free); + PyMem_Free(node_to_free); + } +#endif } static void nb_type_dealloc(PyObject *o) { @@ -495,7 +625,7 @@ static int nb_type_init(PyObject *self, PyObject *args, PyObject *kwds) { t->type_py = (PyTypeObject *) self; t->implicit.cpp = nullptr; t->implicit.py = nullptr; - t->alias_chain = nullptr; + t->foreign_bindings = nullptr; #if defined(Py_LIMITED_API) t->vectorcall = nullptr; @@ -1073,26 +1203,8 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { object modname; PyObject *mod = nullptr; - // Update hash table that maps from std::type_info to Python type - nb_type_map_slow::iterator it; - bool success; nb_internals *internals_ = internals; - { - lock_internals guard(internals_); - std::tie(it, success) = internals_->type_c2p_slow.try_emplace(t->type, nullptr); - if (!success) { - PyErr_WarnFormat(PyExc_RuntimeWarning, 1, - "nanobind: type '%s' was already registered!\n", - t_name); - PyObject *tp = (PyObject *) it->second->type_py; - Py_INCREF(tp); - if (has_signature) - free((char *) t_name); - return tp; - } - } - if (t->scope != nullptr) { if (PyModule_Check(t->scope)) { mod = t->scope; @@ -1144,10 +1256,11 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { } else if (has_base) { lock_internals guard(internals_); nb_type_map_slow::iterator it2 = internals_->type_c2p_slow.find(t->base); - check(it2 != internals_->type_c2p_slow.end(), + check(it2 != internals_->type_c2p_slow.end() && + !nb_is_foreign(it2->second), "nanobind::detail::nb_type_new(\"%s\"): base type \"%s\" not " "known to nanobind!", t_name, type_name(t->base)); - base = (PyObject *) it2->second->type_py; + base = (PyObject *) ((type_data *) it2->second)->type_py; } type_data *tb = nullptr; @@ -1328,8 +1441,6 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { Py_DECREF(metaclass); - make_immortal(result); - type_data *to = nb_type_data((PyTypeObject *) result); *to = *t; // note: slices off _init parts @@ -1354,7 +1465,7 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { to->name = name_copy; to->type_py = (PyTypeObject *) result; - to->alias_chain = nullptr; + to->foreign_bindings = nullptr; to->init = nullptr; if (has_dynamic_attr) @@ -1378,15 +1489,6 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { if (modname.is_valid()) setattr(result, "__module__", modname.ptr()); - { - lock_internals guard(internals_); - internals_->type_c2p_slow[t->type] = to; - - #if !defined(NB_FREE_THREADED) - internals_->type_c2p_fast[t->type] = to; - #endif - } - if (has_signature) { setattr(result, "__nb_signature__", str(t->name)); free((char *) t_name); @@ -1397,6 +1499,19 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { setattr(result, "__orig_bases__", make_tuple(handle(t->base_py))); #endif + // Update hash table that maps from std::type_info to Python type + if (type_data *conflict; !nb_type_register(to, &conflict)) { + PyErr_WarnFormat(PyExc_RuntimeWarning, 1, + "nanobind: type '%s' was already registered!\n", + to->name); + PyObject *tp = (PyObject *) conflict->type_py; + Py_INCREF(tp); + Py_DECREF(result); + return tp; + } + + make_immortal(result); + return result; } @@ -1501,6 +1616,7 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, type_data *dst_type = nullptr; nb_internals *internals_ = internals; + bool has_foreign = false; // If 'src' is a nanobind-bound type if (NB_LIKELY(src_is_nb_type)) { @@ -1512,9 +1628,20 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, // If not, look up the Python type and check the inheritance chain if (NB_UNLIKELY(!valid)) { - dst_type = nb_type_c2p(internals_, cpp_type); + dst_type = nb_type_c2p(internals_, cpp_type, &has_foreign); if (dst_type) valid = PyType_IsSubtype(src_type, dst_type->type_py); + } else { + dst_type = t; +#if !defined(NB_DISABLE_FOREIGN) + // See comment at the end of nb_type_c2p + void *foreign_bindings = t ? nb_load_acquire(t->foreign_bindings) + : nullptr; + has_foreign = t && foreign_bindings && + (nb_is_seq(foreign_bindings) || + ((pymb_binding *) foreign_bindings)->framework != + internals_->foreign_self); +#endif } // Success, return the pointer if the instance is correctly initialized @@ -1553,16 +1680,52 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, } } - // Try an implicit conversion as last resort (if possible & requested) - if ((flags & (uint16_t) cast_flags::convert) && cleanup) { + // Try an implicit conversion (if possible & requested) + if ((flags & (uint8_t) cast_flags::convert) && cleanup) { if (!src_is_nb_type) - dst_type = nb_type_c2p(internals_, cpp_type); + dst_type = nb_type_c2p(internals_, cpp_type, &has_foreign); if (dst_type && - (dst_type->flags & (uint32_t) type_flags::has_implicit_conversions)) - return nb_type_get_implicit(src, cpp_type_src, dst_type, internals_, - cleanup, out); + (dst_type->flags & (uint32_t) type_flags::has_implicit_conversions) && + nb_type_get_implicit(src, cpp_type_src, dst_type, internals_, + cleanup, out)) + return true; + } else if (!src_is_nb_type && internals_->foreign_imported_any) { + // If we never determined the dst type and it might be foreign, + // check for that now. + (void) nb_type_c2p(internals_, cpp_type, &has_foreign); + } + +#if !defined(NB_DISABLE_FOREIGN) + // Try a foreign type + if (has_foreign) { + struct capture { + PyObject *src; + uint8_t flags; + cleanup_list *cleanup; + } cap{src, flags, cleanup}; + + auto attempt = +[](void *closure, pymb_binding *binding) -> void* { + capture &cap = *(capture *) closure; + auto keep_referenced = [](void *ctx, PyObject* item) { + Py_INCREF(item); + ((cleanup_list *) ctx)->append(item); + }; + return binding->framework->from_python( + binding, + cap.src, + bool(cap.flags & (uint16_t) cast_flags::convert), + cap.cleanup ? +keep_referenced : nullptr, + cap.cleanup); + }; + + void *result = nb_type_try_foreign(internals_, cpp_type, attempt, &cap); + if (result) { + *out = result; + return true; + } } +#endif return false; } @@ -1623,6 +1786,15 @@ void keep_alive(PyObject *nurse, PyObject *patient) { if (!weakref) { Py_DECREF(callback); PyErr_Clear(); +#if !defined(NB_DISABLE_FOREIGN) + if (pymb_binding *binding = pymb_get_binding(nurse)) { + // Try a foreign framework's keep_alive as a last resort + if (binding->framework->keep_alive(nurse, patient, + nullptr) == 0) + return; + raise_python_error(); + } +#endif raise("nanobind::detail::keep_alive(): could not create a weak " "reference! Likely, the 'nurse' argument you specified is not " "a weak-referenceable type!"); @@ -1757,6 +1929,48 @@ static PyObject *nb_type_put_common(void *value, type_data *t, rv_policy rvp, return (PyObject *) inst; } +#if !defined(NB_DISABLE_FOREIGN) +static PyObject *nb_type_put_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + const std::type_info *cpp_type_p, + void *value, rv_policy rvp, + cleanup_list *cleanup, + bool *is_new) noexcept { + struct capture { + void *value; + rv_policy rvp; + PyObject *parent; + bool check_new; + bool is_new = false; + } cap{value, rvp, cleanup ? cleanup->self() : nullptr, bool(is_new)}; + + auto attempt = +[](void *closure, pymb_binding *binding) -> void* { + capture &cap = *(capture *) closure; + if (cap.check_new || cap.rvp == rv_policy::none) { + PyObject* existing = binding->framework->to_python( + binding, cap.value, pymb_rv_policy_none, nullptr); + if (existing || cap.rvp == rv_policy::none) { + cap.is_new = false; + return existing; + } + cap.is_new = true; + } + return binding->framework->to_python( + binding, cap.value, (pymb_rv_policy) (uint8_t) cap.rvp, + cap.parent); + }; + + void *result = nullptr; + if (cpp_type_p && cpp_type_p != cpp_type) + result = nb_type_try_foreign(internals_, cpp_type_p, attempt, &cap); + if (!result) + result = nb_type_try_foreign(internals_, cpp_type, attempt, &cap); + if (is_new) + *is_new = cap.is_new; + return (PyObject *) result; +} +#endif + PyObject *nb_type_put(const std::type_info *cpp_type, void *value, rv_policy rvp, cleanup_list *cleanup, @@ -1769,10 +1983,11 @@ PyObject *nb_type_put(const std::type_info *cpp_type, nb_internals *internals_ = internals; type_data *td = nullptr; + bool has_foreign = false; - auto lookup_type = [cpp_type, internals_, &td]() -> bool { + auto lookup_type = [cpp_type, internals_, &td, &has_foreign]() -> bool { if (!td) { - type_data *d = nb_type_c2p(internals_, cpp_type); + type_data *d = nb_type_c2p(internals_, cpp_type, &has_foreign); if (!d) return false; td = d; @@ -1780,6 +1995,17 @@ PyObject *nb_type_put(const std::type_info *cpp_type, return true; }; +#if !defined(NB_DISABLE_FOREIGN) + auto try_foreign = [=, &has_foreign]() -> PyObject* { + if (has_foreign) + return nb_type_put_foreign(internals_, cpp_type, nullptr, value, + rvp, cleanup, is_new); + return nullptr; + }; +#else + auto try_foreign = []() { return nullptr; }; +#endif + if (rvp != rv_policy::copy) { nb_shard &shard = internals_->shard(value); lock_shard guard(shard); @@ -1793,26 +2019,26 @@ PyObject *nb_type_put(const std::type_info *cpp_type, nb_inst_seq seq; if (NB_UNLIKELY(nb_is_seq(entry))) { - seq = *nb_get_seq(entry); + seq = *nb_get_seq(entry); } else { - seq.inst = (PyObject *) entry; + seq.value = (PyObject *) entry; seq.next = nullptr; } while (true) { - PyTypeObject *tp = Py_TYPE(seq.inst); + PyTypeObject *tp = Py_TYPE(seq.value); if (nb_type_data(tp)->type == cpp_type) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (!lookup_type()) - return nullptr; + return try_foreign(); if (PyType_IsSubtype(tp, td->type_py)) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (seq.next == nullptr) @@ -1821,13 +2047,19 @@ PyObject *nb_type_put(const std::type_info *cpp_type, seq = *seq.next; } } else if (rvp == rv_policy::none) { +#if !defined(NB_DISABLE_FOREIGN) + if (internals_->foreign_imported_any) { + (void) lookup_type(); + return try_foreign(); + } +#endif return nullptr; } } // Look up the corresponding Python type if not already done if (!lookup_type()) - return nullptr; + return try_foreign(); return nb_type_put_common(value, td, rvp, cleanup, is_new); } @@ -1849,10 +2081,10 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, // Look up the corresponding Python type type_data *td = nullptr, *td_p = nullptr; - - auto lookup_type = [cpp_type, cpp_type_p, internals_, &td, &td_p]() -> bool { + bool has_foreign = false; + auto lookup_type = [cpp_type, cpp_type_p, internals_, &td, &td_p, &has_foreign]() -> bool { if (!td) { - type_data *d = nb_type_c2p(internals_, cpp_type); + type_data *d = nb_type_c2p(internals_, cpp_type, &has_foreign); if (!d) return false; td = d; @@ -1864,6 +2096,24 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, return true; }; +#if !defined(NB_DISABLE_FOREIGN) + auto try_foreign = [=, &has_foreign]() -> PyObject* { + if (has_foreign) { + if (cpp_type_p && cpp_type_p != cpp_type) { + // To get here, lookup_type() must have returned false, meaning + // we never tried looking up cpp_type_p. Do so now since it's + // required before nb_type_try_foreign. + (void) nb_type_c2p(internals_, cpp_type_p); + } + return nb_type_put_foreign(internals_, cpp_type, cpp_type_p, value, + rvp, cleanup, is_new); + } + return nullptr; + }; +#else + auto try_foreign = []() { return nullptr; }; +#endif + if (rvp != rv_policy::copy) { nb_shard &shard = internals_->shard(value); lock_shard guard(shard); @@ -1877,29 +2127,29 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, nb_inst_seq seq; if (NB_UNLIKELY(nb_is_seq(entry))) { - seq = *nb_get_seq(entry); + seq = *nb_get_seq(entry); } else { - seq.inst = (PyObject *) entry; + seq.value = (PyObject *) entry; seq.next = nullptr; } while (true) { - PyTypeObject *tp = Py_TYPE(seq.inst); + PyTypeObject *tp = Py_TYPE(seq.value); const std::type_info *p = nb_type_data(tp)->type; if (p == cpp_type || p == cpp_type_p) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (!lookup_type()) - return nullptr; + return try_foreign(); if (PyType_IsSubtype(tp, td->type_py) || (td_p && PyType_IsSubtype(tp, td_p->type_py))) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (seq.next == nullptr) @@ -1908,18 +2158,24 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, seq = *seq.next; } } else if (rvp == rv_policy::none) { +#if !defined(NB_DISABLE_FOREIGN) + if (internals_->foreign_imported_any) { + (void) lookup_type(); + return try_foreign(); + } +#endif return nullptr; } } // Look up the corresponding Python type if not already done if (!lookup_type()) - return nullptr; + return try_foreign(); return nb_type_put_common(value, td_p ? td_p : td, rvp, cleanup, is_new); } -static void nb_type_put_unique_finalize(PyObject *o, +static bool nb_type_put_unique_finalize(PyObject *o, const std::type_info *cpp_type, bool cpp_delete, bool is_new) { (void) cpp_type; @@ -1928,6 +2184,25 @@ static void nb_type_put_unique_finalize(PyObject *o, "ownership status has become corrupted.", type_name(cpp_type), cpp_delete); +#if !defined(NB_DISABLE_FOREIGN) + if (!nb_type_check((PyObject *)Py_TYPE(o))) { + if (!is_new) { + // Object already exists on the Python side. Maybe someone + // previously returned T& with rvp::reference, and now + // is returning unique_ptr of the same object. + // Supporting that would require being able to "upgrade" + // a foreign instance from non-owning to owning. We don't try. + PyErr_SetString(PyExc_TypeError, + "Can't return foreign unique_ptr to Python if " + "a Python object already exists for that object"); + return false; + } + // Otherwise no further action is needed; we successfully did + // a cast with rvp::take_ownership, so Python now owns the object. + return true; + } +#endif + nb_inst *inst = (nb_inst *) o; if (cpp_delete) { @@ -1949,6 +2224,7 @@ static void nb_type_put_unique_finalize(PyObject *o, inst->state = nb_inst::state_ready; } + return true; } PyObject *nb_type_put_unique(const std::type_info *cpp_type, @@ -1959,8 +2235,8 @@ PyObject *nb_type_put_unique(const std::type_info *cpp_type, bool is_new = false; PyObject *o = nb_type_put(cpp_type, value, policy, cleanup, &is_new); - if (o) - nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new); + if (o && !nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new)) + Py_CLEAR(o); return o; } @@ -1975,8 +2251,8 @@ PyObject *nb_type_put_unique_p(const std::type_info *cpp_type, PyObject *o = nb_type_put_p(cpp_type, cpp_type_p, value, policy, cleanup, &is_new); - if (o) - nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new); + if (o && !nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new)) + Py_CLEAR(o); return o; } @@ -2044,20 +2320,45 @@ void nb_type_restore_ownership(PyObject *o, bool cpp_delete) noexcept { } } -bool nb_type_isinstance(PyObject *o, const std::type_info *t) noexcept { - type_data *d = nb_type_c2p(internals, t); - if (d) - return PyType_IsSubtype(Py_TYPE(o), d->type_py); - else - return false; +bool nb_type_isinstance(PyObject *o, const std::type_info *t, + bool foreign_ok) noexcept { + bool has_foreign = false; + type_data *d = nb_type_c2p(internals, t, &has_foreign); + if (d && PyType_IsSubtype(Py_TYPE(o), d->type_py)) + return true; +#if !defined(NB_DISABLE_FOREIGN) + if (has_foreign && foreign_ok) + return nullptr != nb_type_try_foreign( + internals, t, + +[](void *closure, pymb_binding *binding) -> void* { + return PyType_IsSubtype((PyTypeObject *) closure, + binding->pytype) + ? binding : nullptr; + }, + Py_TYPE(o)); +#else + (void) foreign_ok; +#endif + return false; } -PyObject *nb_type_lookup(const std::type_info *t) noexcept { - type_data *d = nb_type_c2p(internals, t); +PyObject *nb_type_lookup(const std::type_info *t, bool foreign_ok) noexcept { + bool has_foreign = false; + type_data *d = nb_type_c2p(internals, t, &has_foreign); if (d) return (PyObject *) d->type_py; - else - return nullptr; +#if !defined(NB_DISABLE_FOREIGN) + if (has_foreign && foreign_ok) + return (PyObject *) nb_type_try_foreign( + internals, t, + +[](void *, pymb_binding *binding) -> void* { + return binding->pytype; + }, + nullptr); +#else + (void) foreign_ok; +#endif + return nullptr; } bool nb_type_check(PyObject *t) noexcept { @@ -2279,5 +2580,40 @@ bool nb_inst_python_derived(PyObject *o) noexcept { (uint32_t) type_flags::is_python_type; } +void nb_type_set_foreign_defaults(bool export_all, bool import_all) { +#if !defined(NB_DISABLE_FOREIGN) + if (import_all && !internals->foreign_import_all) + nb_type_enable_import_all(); + if (export_all && !internals->foreign_export_all) + nb_type_enable_export_all(); +#else + if (export_all || import_all) + raise("This libnanobind was built without foreign type support"); +#endif +} + +void nb_type_import(PyObject *pytype, const std::type_info *cpptype) { +#if !defined(NB_DISABLE_FOREIGN) + lock_internals guard{internals}; + check(PyType_Check(pytype), "not a type object"); + nb_type_import_impl(pytype, cpptype); +#else + (void) pytype; + (void) cpptype; + raise("This libnanobind was built without foreign type support"); +#endif +} + +void nb_type_export(PyObject *pytype) { +#if !defined(NB_DISABLE_FOREIGN) + lock_internals guard{internals}; + check(nb_type_check(pytype), "not a nanobind type"); + nb_type_export_impl(nb_type_data((PyTypeObject *) pytype)); +#else + (void) pytype; + raise("This libnanobind was built without foreign type support"); +#endif +} + NAMESPACE_END(detail) NAMESPACE_END(NB_NAMESPACE) diff --git a/src/pymetabind.h b/src/pymetabind.h new file mode 100644 index 000000000..d42cd9c7e --- /dev/null +++ b/src/pymetabind.h @@ -0,0 +1,709 @@ +/* + * pymetabind.h: definitions for interoperability between different + * Python binding frameworks + * + * Copy this header file into the implementation of a framework that uses it. + * This functionality is intended to be used by the framework itself, + * rather than by users of the framework. + * + * This is version 0.1 of pymetabind. Changelog: + * + * Version 0.1: Initial draft. ABI may change without warning while we + * 2025-08-16 prove out the concept. Please wait for a 1.0 release + * before including this header in a published release of + * any binding framework. + * + * Copyright (c) 2025 Hudson River Trading + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include + +#if !defined(PY_VERSION_HEX) +# error You must include Python.h before this header +#endif + +/* + * There are two ways to use this header file. The default is header-only style, + * where all functions are defined as `inline`. If you want to emit functions + * as non-inline, perhaps so you can link against them from non-C/C++ code, + * then do the following: + * - In every compilation unit that includes this header, `#define PYMB_FUNC` + * first. (The `PYMB_FUNC` macro will be expanded in place of the "inline" + * keyword, so you can also use it to add any other declaration attributes + * required by your environment.) + * - In all those compilation units except one, also `#define PYMB_DECLS_ONLY` + * before including this header. The definitions will be emitted in the + * compilation unit that doesn't request `PYMB_DECLS_ONLY`. + */ +#if !defined(PYMB_FUNC) +#define PYMB_FUNC inline +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +/* + * Approach used to cast a previously unknown C++ instance into a Python object. + * The values of these enumerators match those for `nanobind::rv_policy` and + * `pybind11::return_value_policy`. + */ +enum pymb_rv_policy { + // (Values 0 and 1 correspond to `automatic` and `automatic_reference`, + // which should become one of the other policies before reaching us) + + // Create a Python object that owns a pointer to heap-allocated storage + // and will destroy and deallocate it when the Python object is destroyed + pymb_rv_policy_take_ownership = 2, + + // Create a Python object that owns a new C++ instance created via + // copy construction from the given one + pymb_rv_policy_copy = 3, + + // Create a Python object that owns a new C++ instance created via + // move construction from the given one + pymb_rv_policy_move = 4, + + // Create a Python object that wraps the given pointer to a C++ instance + // but will not destroy or deallocate it + pymb_rv_policy_reference = 5, + + // `reference`, plus arrange for the given `parent` python object to + // live at least as long as the new object that wraps the pointer + pymb_rv_policy_reference_internal = 6, + + // Don't create a new Python object; only try to look up an existing one + // from the same framework + pymb_rv_policy_none = 7 +}; + +/* + * The language to which a particular framework provides bindings. Each + * language has its own semantics for how to interpret + * `pymb_framework::abi_extra` and `pymb_binding::native_type`. + */ +enum pymb_abi_lang { + // C. `pymb_framework::abi_extra` and `pymb_binding::native_type` are NULL. + pymb_abi_lang_c = 1, + + // C++. `pymb_framework::abi_extra` is in the format used by + // nanobind since 2.6.1 (NB_PLATFORM_ABI_TAG in nanobind/src/nb_abi.h) + // and pybind11 since 2.11.2/2.12.1/2.13.6 (PYBIND11_PLATFORM_ABI_ID in + // pybind11/include/pybind11/conduit/pybind11_platform_abi_id.h). + // `pymb_binding::native_type` is a cast `const std::type_info*` pointer. + pymb_abi_lang_cpp = 2, + + // extensions welcome! +}; + +/* + * Simple linked list implementation. `pymb_list_node` should be the first + * member of a structure so you can downcast it to the appropriate type. + */ +struct pymb_list_node { + struct pymb_list_node *next; + struct pymb_list_node *prev; +}; + +struct pymb_list { + struct pymb_list_node head; +}; + +inline void pymb_list_init(struct pymb_list* list) { + list->head.prev = list->head.next = &list->head; +} + +inline void pymb_list_unlink(struct pymb_list_node* node) { + if (node->next) { + node->next->prev = node->prev; + node->prev->next = node->next; + node->next = node->prev = NULL; + } +} + +inline void pymb_list_append(struct pymb_list* list, + struct pymb_list_node* node) { + pymb_list_unlink(node); + struct pymb_list_node* tail = list->head.prev; + tail->next = node; + list->head.prev = node; + node->prev = tail; + node->next = &list->head; +} + +#define PYMB_LIST_FOREACH(type, name, list) \ + for (type name = (type) (list).head.next; \ + name != (type) &(list).head; \ + name = (type) name->hook.next) + +/* + * The registry holds information about all the interoperable binding + * frameworks and individual type bindings that are loaded in a Python + * interpreter process. It is protected by a mutex in free-threaded builds, + * and by the GIL in regular builds. + * + * The only data structure we use is a C doubly-linked list, which offers a + * lowest-common-denominator ABI and cheap addition and removal. It is expected + * that individual binding frameworks will use their `add_foreign_binding` and + * `remove_foreign_binding` callbacks to maintain references to these structures + * in more-performant private data structures of their choosing. + * + * The pointer to the registry is stored in a Python capsule object with type + * "pymetabind_registry", which is stored in the PyInterpreterState_GetDict() + * under the string key "__pymetabind_registry__". Any ABI-incompatible changes + * after v1.0 (which we hope to avoid!) will result in a new name for the + * dictionary key. You can obtain a registry pointer using + * `pymb_get_registry()`, defined below. + */ +struct pymb_registry { + // Linked list of registered `pymb_framework` structures + struct pymb_list frameworks; + + // Linked list of registered `pymb_binding` structures + struct pymb_list bindings; + + // Reserved for future extensions; currently set to 0 + uint32_t reserved; + +#if defined(Py_GIL_DISABLED) + // Mutex guarding accesses to `frameworks` and `bindings`. + // On non-free-threading builds, these are guarded by the Python GIL. + PyMutex mutex; +#endif +}; + +#if defined(Py_GIL_DISALED) +inline void pymb_lock_registry(struct pymb_registry* registry) { + PyMutex_Lock(®istry->mutex); +} +inline void pymb_unlock_registry(struct pymb_registry* registry) { + PyMutex_Unlock(®istry->mutex); +} +#else +inline void pymb_lock_registry(struct pymb_registry*) {} +inline void pymb_unlock_registry(struct pymb_registry*) {} +#endif + +struct pymb_binding; + +/* + * Information about one framework that has registered itself with pymetabind. + * "Framework" here refers to a set of bindings that are natively mutually + * interoperable. So, different binding libraries would be different frameworks, + * as would versions of the same library that use incompatible data structures + * due to ABI changes or build flags. + * + * A framework that wishes to either export bindings (allow other frameworks + * to perform to/from Python conversion for its types) or import bindings + * (perform its own to/from Python conversion for other frameworks' types) + * must start by creating and filling out a `pymb_framework` structure. + * This can be allocated in any way that the framework prefers (e.g., on + * the heap or in static storage). Once filled out, the framework structure + * should be passed to `pymb_add_framework()`. It must then remain accessible + * and unmodified (except as documented below) until the Python interpreter + * is finalized. After finalization, such as in a `Py_AtExit` handler, if + * all bindings have been removed already, you may optionally clean up by + * calling `pymb_list_unlink(&framework->hook)` and then deallocating the + * `pymb_framework` structure. + * + * All fields of this structure are set before it is made visible to other + * threads and then never changed, so they don't need locking to access. + * Some methods require locking or other synchronization to call; see their + * individual documentation. + */ +struct pymb_framework { + // Hook by which this structure is linked into the list of + // `pymb_registry::frameworks`. May be modified as other frameworks are + // added; protected by the `pymb_registry::mutex` in free-threaded builds. + struct pymb_list_node hook; + + // Human-readable description of this framework, as a NUL-terminated string + const char* name; + + // Does this framework guarantee that its `pymb_binding` structures remain + // valid to use for the lifetime of the Python interpreter process once + // they have been linked into the lists in `pymb_registry`? Setting this + // to true reduces the number of atomic operations needed to work with + // this framework's bindings in free-threaded builds. + uint8_t bindings_usable_forever; + + // Reserved for future extensions. Set to 0. + uint8_t reserved[3]; + + // The language to which this framework provides bindings: one of the + // `pymb_abi_lang` enumerators. + enum pymb_abi_lang abi_lang; + + // NUL-terminated string constant encoding additional information that must + // match in order for two types with the same `abi_lang` to be usable from + // each other's environments. See documentation of `abi_lang` enumerators + // for language-specific guidance. This may be NULL if there are no + // additional ABI details that are relevant for your language. + // + // This is only the platform details that affect things like the layout + // of objects provided by the `abi_lang` (std::string, etc); Python build + // details (free-threaded, stable ABI, etc) should not impact this string. + // Details that are already guaranteed to match by virtue of being in the + // same address space -- architecture, pointer size, OS -- also should not + // impact this string. + // + // For efficiency, `pymb_add_framework()` will compare this against every + // other registered framework's `abi_extra` tag, and re-point an incoming + // framework's `abi_extra` field to refer to the matching `abi_extra` string + // of an already-registered framework if one exists. This acts as a simple + // form of interning to speed up checking that a given binding is usable. + // Thus, to check whether another framework's ABI matches yours, you can + // do a pointer comparison `me->abi_extra == them->abi_extra`. + const char* abi_extra; + + // The function pointers below allow other frameworks to interact with + // bindings provided by this framework. They are constant after construction + // and, except for `translate_exception()`, must not throw C++ exceptions. + // Unless otherwise documented, they must not be NULL. + + // Extract a C/C++/etc object from `pyobj`. The desired type is specified by + // providing a `pymb_binding*` for some binding that belongs to this + // framework. Return a pointer to the object, or NULL if no pointer of the + // appropriate type could be extracted. + // + // If `convert` is nonzero, be more willing to perform implicit conversions + // to make the cast succeed; the intent is that one could perform overload + // resolution by doing a first pass with convert=false to find an exact + // match, and then a second with convert=true to find an approximate match + // if there's no exact match. + // + // If `keep_referenced` is not NULL, then `from_python` may make calls to + // `keep_referenced` to request that some Python objects remain referenced + // until the returned object is no longer needed. The `keep_referenced_ctx` + // will be passed as the first argument to any such calls. + // `keep_referenced` should incref its `obj` immediately and remember + // that it should be decref'ed later, for no net change in refcount. + // This is an abstraction around something like the cleanup_list in + // nanobind or loader_life_support in pybind11. + // + // On free-threaded builds, callers must ensure that the `binding` is not + // destroyed during a call to `from_python`. The requirements for this are + // subtle; see the full discussion in the comment for `struct pymb_binding`. + void* (*from_python)(struct pymb_binding* binding, + PyObject* pyobj, + uint8_t convert, + void (*keep_referenced)(void* ctx, PyObject* obj), + void* keep_referenced_ctx); + + // Wrap the C/C++/etc object `cobj` into a Python object using the given + // return value policy. The type is specified by providing a `pymb_binding*` + // for some binding that belongs to this framework. `parent` is relevant + // only if `rvp == pymb_rv_policy_reference_internal`. rvp must be one of + // the defined enumerators. Returns NULL if the cast is not possible, or + // a new reference otherwise. + // + // A NULL return may leave the Python error indicator set if something + // specifically describable went wrong during conversion, but is not + // required to; returning NULL without PyErr_Occurred() should be + // interpreted as a generic failure to convert `cobj` to a Python object. + // + // On free-threaded builds, callers must ensure that the `binding` is not + // destroyed during a call to `to_python`. The requirements for this are + // subtle; see the full discussion in the comment for `struct pymb_binding`. + PyObject* (*to_python)(struct pymb_binding* binding, + void* cobj, + enum pymb_rv_policy rvp, + PyObject* parent); + + // Request that a PyObject reference be dropped, or that a callback + // be invoked, when `nurse` is destroyed. `nurse` should be an object + // whose type is bound by this framework. If `cb` is NULL, then + // `payload` is a PyObject* to decref; otherwise `payload` will + // be passed as the argument to `cb`. Returns 0 if successful, + // or -1 and sets the Python error indicator on error. + // + // No synchronization is required to call this method. + int (*keep_alive)(PyObject* nurse, void* payload, void (*cb)(void*)); + + // Attempt to translate a C++ exception known to this framework to Python. + // This should translate only framework-specific exceptions or user-defined + // exceptions that were registered with the framework, not generic + // ones such as `std::exception`. If successful, return normally with the + // Python error indicator set; otherwise, reraise the provided exception. + // `eptr` should be cast to `const std::exception_ptr* eptr` before use. + // This function pointer may be NULL if this framework does not provide + // C++ exception translation. + // + // No synchronization is required to call this method. + void (*translate_exception)(const void* eptr); + + // Notify this framework that some other framework published a new binding. + // This call will be made after the new binding has been linked into the + // `pymb_registry::bindings` list. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*add_foreign_binding)(struct pymb_binding* binding); + + // Notify this framework that some other framework is about to remove + // a binding. This call will be made after the binding has been removed + // from the `pymb_registry::bindings` list. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*remove_foreign_binding)(struct pymb_binding* binding); + + // Notify this framework that some other framework came into existence. + // This call will be made after the new framework has been linked into the + // `pymb_registry::frameworks` list and before it adds any bindings. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*add_foreign_framework)(struct pymb_framework* framework); + + // There is no remove_foreign_framework(); the interpreter has + // already been finalized at that point, so there's nothing for the + // callback to do. +}; + +/* + * Information about one type binding that belongs to a registered framework. + * + * A framework that binds some type and wants to allow other frameworks to + * work with objects of that type must create a `pymb_binding` structure for + * the type. This can be allocated in any way that the framework prefers (e.g., + * on the heap or within the type object). Once filled out, the binding + * structure should be passed to `pymb_add_binding()`. If the Python type object + * underlying the binding is to be deallocated, a `pymb_remove_binding()` call + * must be made, and the `pymb_binding` structure cannot be deallocated until + * `pymb_remove_binding()` returns. The call to `pymb_remove_binding()` + * must occur *during* deallocation of the binding's Python type object, i.e., + * at a time when `Py_REFCNT(pytype) == 0` but the storage for `pytype` is not + * yet eligible to be reused for another object. Many frameworks use a custom + * metaclass, and can add the call to `pymb_remove_binding()` from the metaclass + * `tp_dealloc`; those that don't can use a weakref callback on the type object + * instead. The constraint on destruction timing allows `pymb_try_ref_binding()` + * to temporarily prevent the binding's destruction by incrementing the type + * object's reference count. + * + * Each Python type object for which a `pymb_binding` exists will have an + * attribute "__pymetabind_binding__" whose value is a capsule object + * that contains the `pymb_binding` pointer under the name "pymetabind_binding". + * The attribute is set during `pymb_add_binding()`. This is provided to allow: + * - Determining which framework to call for a foreign `keep_alive` operation + * - Locating `pymb_binding` objects for types written in a different language + * than yours (where you can't look up by the `pymb_binding::native_type`), + * so that you can work with their contents using non-Python-specific + * cross-language support + * - Extracting the native object from a Python object without being too picky + * about what type it is (risky, but maybe you have out-of-band information + * that shows it's safe) + * The preferred mechanism for same-language object access is to maintain a + * hashtable keyed on `pymb_binding::native_type` and look up the binding for + * the type you want/have. Compared to reading the capsule, this better + * supports inheritance, to-Python conversions, and implicit conversions, and + * it's probably also faster depending on how it's implemented. + * + * It is valid for multiple frameworks to claim (in separate bindings) the + * same C/C++ type, or even the same Python type. (A case where multiple + * frameworks would bind the same Python type is if one is acting as an + * extension to the other, such as to support extracting pointers to + * non-primary base classes when the base framework doesn't think about + * such things.) If multiple frameworks claim the same Python type, then each + * new registrant will replace the "__pymetabind_binding__" capsule and there + * is no way to locate the other bindings from the type object. + * + * All fields of this structure are set before it is made visible to other + * threads and then never changed, so they don't need locking to access. + * However, on free-threaded builds it is necessary to validate that the type + * object is not partway through being destroyed before you use the binding, + * and prevent such destruction from beginning until you're done. To do so, + * call `pymb_try_ref_binding()`; if it returns false, don't use the binding, + * else use it and then call `pymb_unref_binding()` when done. + * (On non-free-threaded builds, these do incref/decref to prevent destruction + * of the type from starting, but can't fail because there's no *concurrent* + * destruction hazard.) + * + * In order to work with one framework's Python objects of a certain type, other + * frameworks must be able to locate a `pymb_binding` structure for that type. + * It is expected that they will maintain their own type-to-binding maps, which + * they can keep up-to-date via their `pymb_framework::add_foreign_binding` and + * `pymb_framework::remove_foreign_binding` hooks. It is important to think very + * carefully about how to design the synchronization for these maps so that + * lookups do not return pointers to bindings that have been deallocated. + * The remainder of this comment provides some suggestions. + * + * The recommended way to handle synchronization is to protect your type lookup + * map with a readers/writer lock. In your `remove_foreign_binding` hook, + * obtain a write lock, and hold it while removing the corresponding entry from + * the map. Before performing a type lookup, obtain a read lock. If the lookup + * succeeds, call `pymb_try_ref_binding()` on the resulting binding before + * you release your read lock. Since the binding structure can't be deallocated + * until all `remove_foreign_binding` hooks have returned, this scheme provides + * effective protection. It is important not to hold the read lock while + * executing arbitrary Python code, since a deadlock would result if the type + * object is deallocated (requiring a write lock) while the read lock were held. + * Note that `pymb_framework::from_python` for many popular frameworks is + * capable of executing arbitrary Python code to perform implicit conversions. + * + * The lock on a single shared type lookup map is a contention bottleneck, + * especially if you don't have a readers/writer lock and wish to get by with + * an ordinary mutex. To improve performance, you can give each thread its + * own lookup map, and require `remove_foreign_binding` to update all of them. + * As long as the per-thread maps are always visited in a consistent order + * when removing a binding, the splitting shouldn't introduce new deadlocks. + * Since each thread has a separate mutex for its separate map, contention + * occurs only when bindings are being added or removed, which is much less + * common than using them. + */ +struct pymb_binding { + // Hook by which this structure is linked into the list of + // `pymb_registry::bindings` + struct pymb_list_node hook; + + // The framework that provides this binding + struct pymb_framework* framework; + + // Python type: you will get an instance of this type from a successful + // call to `framework::from_python()` that passes this binding + PyTypeObject* pytype; + + // The native identifier for this type in `framework->abi_lang`, if that is + // a concept that exists in that language. See the documentation of + // `enum pymb_abi_lang` for specific per-language semantics. + const void* native_type; + + // The way that this type would be written in `framework->abi_lang` source + // code, as a NUL-terminated byte string without struct/class/enum words. + // Examples: "Foo", "Bar::Baz", "std::vector >" + const char* source_name; + + // Pointer that is free for use by the framework, e.g., to point to its + // own data about this type. If the framework needs more data, it can + // over-allocate the `pymb_binding` storage and use the space after this. + void* context; +}; + +/* + * Users of non-C/C++ languages are welcome to replicate the logic of these + * inline functions rather than calling them. Their implementations are + * considered part of the ABI. + */ + +PYMB_FUNC struct pymb_registry* pymb_get_registry(); +PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, + struct pymb_framework* framework); +PYMB_FUNC void pymb_remove_framework(struct pymb_registry* registry, + struct pymb_framework* framework); +PYMB_FUNC void pymb_add_binding(struct pymb_registry* registry, + struct pymb_binding* binding); +PYMB_FUNC void pymb_remove_binding(struct pymb_registry* registry, + struct pymb_binding* binding); +PYMB_FUNC int pymb_try_ref_binding(struct pymb_binding* binding); +PYMB_FUNC void pymb_unref_binding(struct pymb_binding* binding); +PYMB_FUNC struct pymb_binding* pymb_get_binding(PyObject* type); + +#if !defined(PYMB_DECLS_ONLY) + +/* + * Locate an existing `pymb_registry`, or create a new one if necessary. + * Returns a pointer to it, or NULL with the CPython error indicator set. + * This must be called from a module initialization function so that the + * import lock can provide mutual exclusion. + */ +PYMB_FUNC struct pymb_registry* pymb_get_registry() { +#if defined(PYPY_VERSION) + PyObject* dict = PyEval_GetBuiltins(); +#elif PY_VERSION_HEX < 0x03090000 + PyObject* dict = PyInterpreterState_GetDict(_PyInterpreterState_Get()); +#else + PyObject* dict = PyInterpreterState_GetDict(PyInterpreterState_Get()); +#endif + PyObject* key = PyUnicode_FromString("__pymetabind_registry__"); + if (!dict || !key) { + Py_XDECREF(key); + return NULL; + } + PyObject* capsule = PyDict_GetItem(dict, key); + if (capsule) { + Py_DECREF(key); + return (struct pymb_registry*) PyCapsule_GetPointer( + capsule, "pymetabind_registry"); + } + struct pymb_registry* registry; + registry = (struct pymb_registry*) calloc(1, sizeof(*registry)); + if (registry) { + pymb_list_init(®istry->frameworks); + pymb_list_init(®istry->bindings); + capsule = PyCapsule_New(registry, "pymetabind_registry", NULL); + int rv = capsule ? PyDict_SetItem(dict, key, capsule) : -1; + Py_XDECREF(capsule); + if (rv != 0) { + free(registry); + registry = NULL; + } + } else { + PyErr_NoMemory(); + } + Py_DECREF(key); + return registry; +} + +/* + * Add a new framework to the given registry. Makes calls to + * framework->add_foreign_framework() and framework->add_foreign_binding() + * for each existing framework/binding in the registry. + */ +PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, + struct pymb_framework* framework) { +#if defined(Py_GIL_DISABLED) && PY_VERSION_HEX < 0x030e0000 + assert(framework->bindings_usable_forever && + "Free-threaded removal of bindings requires PyUnstable_TryIncRef(), " + "which was added in CPython 3.14"); +#endif + pymb_lock_registry(registry); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + // Intern `abi_extra` strings so they can be compared by pointer + if (other->abi_extra && framework->abi_extra && + 0 == strcmp(other->abi_extra, framework->abi_extra)) { + framework->abi_extra = other->abi_extra; + break; + } + } + pymb_list_append(®istry->frameworks, &framework->hook); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + if (other != framework) { + other->add_foreign_framework(framework); + framework->add_foreign_framework(other); + } + } + PYMB_LIST_FOREACH(struct pymb_binding*, binding, registry->bindings) { + if (binding->framework != framework && pymb_try_ref_binding(binding)) { + framework->add_foreign_binding(binding); + pymb_unref_binding(binding); + } + } + pymb_unlock_registry(registry); +} + +/* Add a new binding to the given registry */ +PYMB_FUNC void pymb_add_binding(struct pymb_registry* registry, + struct pymb_binding* binding) { +#if defined(Py_GIL_DISABLED) && PY_VERSION_HEX >= 0x030e0000 + PyUnstable_EnableTryIncRef((PyObject *) binding->pytype); +#endif + PyObject* capsule = PyCapsule_New(binding, "pymetabind_binding", NULL); + int rv = -1; + if (capsule) { + rv = PyObject_SetAttrString((PyObject *) binding->pytype, + "__pymetabind_binding__", capsule); + Py_DECREF(capsule); + } + if (rv != 0) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } + pymb_lock_registry(registry); + pymb_list_append(®istry->bindings, &binding->hook); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + if (other != binding->framework) { + other->add_foreign_binding(binding); + } + } + pymb_unlock_registry(registry); +} + +/* + * Remove a binding from the given registry. This must be called during + * deallocation of the `binding->pytype`, such that its reference count is + * zero but still accessible. Once this function returns, you can free the + * binding structure. + */ +PYMB_FUNC void pymb_remove_binding(struct pymb_registry* registry, + struct pymb_binding* binding) { + pymb_lock_registry(registry); + pymb_list_unlink(&binding->hook); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + if (other != binding->framework) { + other->remove_foreign_binding(binding); + } + } + pymb_unlock_registry(registry); +} + +/* + * Increase the reference count of a binding. Return 1 if successful (you can + * use the binding and must call pymb_unref_binding() when done) or 0 if the + * binding is being removed and shouldn't be used. + */ +PYMB_FUNC int pymb_try_ref_binding(struct pymb_binding* binding) { +#if defined(Py_GIL_DISABLED) + if (!binding->framework->bindings_usable_forever) { +#if PY_VERSION_HEX >= 0x030e0000 + return PyUnstable_TryIncRef((PyObject *) binding->pytype); +#else + // bindings_usable_forever is required on this Python version, and + // was checked in pymb_add_framework() + assert(false); +#endif + } +#else + Py_INCREF((PyObject *) binding->pytype); +#endif + return 1; +} + +/* Decrease the reference count of a binding. */ +PYMB_FUNC void pymb_unref_binding(struct pymb_binding* binding) { +#if defined(Py_GIL_DISABLED) + if (!binding->framework->bindings_usable_forever) { +#if PY_VERSION_HEX >= 0x030e0000 + Py_DECREF((PyObject *) binding->pytype); +#else + // bindings_usable_forever is required on this Python version, and + // was checked in pymb_add_framework() + assert(false); +#endif + } +#else + Py_DECREF((PyObject *) binding->pytype); +#endif +} + +/* + * Return a pointer to a pymb_binding for the Python type `type`, or NULL if + * none exists. + */ +PYMB_FUNC struct pymb_binding* pymb_get_binding(PyObject* type) { + PyObject* capsule = PyObject_GetAttrString(type, "__pymetabind_binding__"); + if (capsule == NULL) { + PyErr_Clear(); + return NULL; + } + void* binding = PyCapsule_GetPointer(capsule, "pymetabind_binding"); + Py_DECREF(capsule); + if (!binding) { + PyErr_Clear(); + } + return (struct pymb_binding*) binding; +} + +#endif /* defined(PYMB_DECLS_ONLY) */ + +#if defined(__cplusplus) +} +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b43eaaa7d..a2b2b37fa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,10 @@ if (NB_TEST_SHARED_BUILD) set(NB_EXTRA_ARGS ${NB_EXTRA_ARGS} NB_SHARED) endif() +if (NB_TEST_NO_INTEROP) + set(NB_EXTRA_ARGS ${NB_EXTRA_ARGS} NO_INTEROP) +endif() + # --------------------------------------------------------------------------- # Compile with a few more compiler warnings turned on # --------------------------------------------------------------------------- From 26971e0be0d5690c7f54d2ea7612b6e1ad2dc279 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Mon, 18 Aug 2025 13:28:20 -0600 Subject: [PATCH 2/8] Self-review + add to nb_combined.cpp --- src/nb_combined.cpp | 1 + src/nb_foreign.cpp | 43 ++++++++++++++++++++++++++++--------------- src/nb_internals.h | 7 +++++++ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/nb_combined.cpp b/src/nb_combined.cpp index f565ce09f..6263cef43 100644 --- a/src/nb_combined.cpp +++ b/src/nb_combined.cpp @@ -79,6 +79,7 @@ #include "nb_ndarray.cpp" #include "nb_static_property.cpp" #include "nb_ft.cpp" +#include "nb_foreign.cpp" #include "error.cpp" #include "common.cpp" #include "implicit.cpp" diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp index 79343cdc6..82064bcf4 100644 --- a/src/nb_foreign.cpp +++ b/src/nb_foreign.cpp @@ -78,25 +78,26 @@ static int nb_foreign_keep_alive(PyObject *nurse, keep_alive(nurse, payload, (void (*)(void*) noexcept) cb); else keep_alive(nurse, (PyObject *) payload); - return true; + return 0; } catch (const std::runtime_error& err) { PyErr_SetString(PyExc_RuntimeError, err.what()); - return false; + return -1; } } static void nb_foreign_translate_exception(const void *eptr) { std::exception_ptr e = *(const std::exception_ptr *) eptr; + + // Skip the default translator (at the end of the list). It translates + // generic STL exceptions which other frameworks might want to translate + // differently than we do; they should get control over the behavior of + // their functions. for (nb_translator_seq* cur = internals->translators.load_acquire(); - cur; cur = cur->next.load_acquire()) { - if (cur->translator == default_exception_translator || - cur->translator == foreign_exception_translator) { - // The default translator translates generic STL exceptions which - // other frameworks might want to translate differently than we do; - // they should get control over the behavior of their functions. - // Don't call foreign translators to avoid mutual recursion. - // Both these are at the end of the list, so we can stop iterating - // when we see one. + cur->next.load_relaxed(); cur = cur->next.load_acquire()) { + if (cur->translator == internals->foreign_exception_translator) { + // Don't call foreign translators, to avoid mutual recursion. + // They are at the end of the list, just before the default + // translator, so we can stop iterating when we see one. break; } try { @@ -187,8 +188,16 @@ static void nb_foreign_remove_foreign_binding(pymb_binding *binding) noexcept { static void nb_foreign_add_foreign_framework(pymb_framework *framework) noexcept { - register_exception_translator(foreign_exception_translator, framework, - /*at_end=*/true); + if (framework->translate_exception) { + { + lock_internals guard{internals}; + if (!internals->foreign_exception_translator) + internals->foreign_exception_translator = + foreign_exception_translator; + } + register_exception_translator(internals->foreign_exception_translator, + framework, /*at_end=*/true); + } internals->print_leak_warnings &= !framework->bindings_usable_forever; } @@ -293,8 +302,8 @@ void nb_type_import_impl(PyObject *pytype, const std::type_info *cpptype) { raise("'%s' is already bound by this nanobind domain", name); if (!cpptype) { if (binding->framework->abi_lang != pymb_abi_lang_cpp) - raise("'%s' is not written in C++, so you must provide a C++ type", - name); + raise("'%s' is not written in C++, so you must specify a C++ type " + "to map it to", name); if (binding->framework->abi_extra != foreign_self->abi_extra) raise("'%s' has incompatible C++ ABI with this nanobind domain: " "their '%s' vs our '%s'", name, binding->framework->abi_extra, @@ -321,6 +330,8 @@ void nb_type_enable_import_all() { nb_internals *internals_ = internals; { lock_internals guard{internals_}; + if (internals_->foreign_import_all) + return; internals_->foreign_import_all = true; if (!internals_->foreign_registry) { // pymb_add_framework tells us about every existing type when we @@ -391,6 +402,8 @@ void nb_type_export_impl(type_data *td) { void nb_type_enable_export_all() { nb_internals *internals_ = internals; lock_internals guard{internals_}; + if (internals_->foreign_export_all) + return; internals_->foreign_export_all = true; if (!internals_->foreign_registry) register_with_pymetabind(internals_); diff --git a/src/nb_internals.h b/src/nb_internals.h index 30100df02..6378fda63 100644 --- a/src/nb_internals.h +++ b/src/nb_internals.h @@ -695,6 +695,13 @@ struct nb_internals { /// C++ types via nb::import_foreign_type() nb_ptr_map foreign_manual_imports; + /// Pointer to the canonical copy of `foreign_exception_translator()` + /// from nb_foreign.cpp. Each DSO may have a different copy, but all will + /// use the implementation from the first DSO to need it, so that + /// `nb_foreign_translate_exception()` (translating our exceptions for + /// a foreign framework's benefit) can skip foreign translators. + void (*foreign_exception_translator)(const std::exception_ptr&, void*); + /// Pointer to a boolean that denotes if nanobind is fully initialized. bool *is_alive_ptr = nullptr; From 940500dd2e6d35c6a9ab0184eb360005a4dbe23e Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Thu, 21 Aug 2025 10:55:14 -0600 Subject: [PATCH 3/8] Update pymetabind --- src/nb_foreign.cpp | 2 ++ src/pymetabind.h | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp index 82064bcf4..b2ecf2813 100644 --- a/src/nb_foreign.cpp +++ b/src/nb_foreign.cpp @@ -216,8 +216,10 @@ static void register_with_pymetabind(nb_internals *internals_) { fw->name = "nanobind " NB_ABI_TAG; #if defined(NB_FREE_THREADED) fw->bindings_usable_forever = 1; + fw->leak_safe = 0; #else fw->bindings_usable_forever = 0; + fw->leak_safe = 1; #endif fw->abi_lang = pymb_abi_lang_cpp; fw->abi_extra = NB_PLATFORM_ABI_TAG; diff --git a/src/pymetabind.h b/src/pymetabind.h index d42cd9c7e..d7bf2628b 100644 --- a/src/pymetabind.h +++ b/src/pymetabind.h @@ -6,7 +6,10 @@ * This functionality is intended to be used by the framework itself, * rather than by users of the framework. * - * This is version 0.1 of pymetabind. Changelog: + * This is version 0.1+dev of pymetabind. Changelog: + * + * Unreleased: Fix typo in Py_GIL_DISABLED. Add pymb_framework::leak_safe. + * Add casts from PyTypeObject* to PyObject* where needed. * * Version 0.1: Initial draft. ABI may change without warning while we * 2025-08-16 prove out the concept. Please wait for a 1.0 release @@ -195,7 +198,7 @@ struct pymb_registry { #endif }; -#if defined(Py_GIL_DISALED) +#if defined(Py_GIL_DISABLED) inline void pymb_lock_registry(struct pymb_registry* registry) { PyMutex_Lock(®istry->mutex); } @@ -250,8 +253,15 @@ struct pymb_framework { // this framework's bindings in free-threaded builds. uint8_t bindings_usable_forever; + // Does this framework reliably deallocate all of its type and function + // objects by the time the Python interpreter is finalized, in the absence + // of bugs in user code? If not, it might cause leaks of other frameworks' + // types or functions, via attributes or default argument values for + // this framework's leaked objects. + uint8_t leak_safe; + // Reserved for future extensions. Set to 0. - uint8_t reserved[3]; + uint8_t reserved[2]; // The language to which this framework provides bindings: one of the // `pymb_abi_lang` enumerators. From 6769a725437386e66d5f1937977d20ab3f96209d Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Mon, 8 Sep 2025 11:11:58 -0600 Subject: [PATCH 4/8] Initial updates after review --- include/nanobind/nb_class.h | 3 + include/nanobind/stl/unique_ptr.h | 3 +- src/nb_foreign.cpp | 42 +++++++---- src/nb_internals.cpp | 2 +- src/pymetabind.h | 118 ++++++++++++++++++------------ 5 files changed, 104 insertions(+), 64 deletions(-) diff --git a/include/nanobind/nb_class.h b/include/nanobind/nb_class.h index 6e28ef38f..8840585f2 100644 --- a/include/nanobind/nb_class.h +++ b/include/nanobind/nb_class.h @@ -111,6 +111,9 @@ struct type_data { const char *name; const std::type_info *type; PyTypeObject *type_py; + // If not null, then nanobind is aware of other frameworks' bindings for + // this C++ type. Discriminated pointer: pymb_binding* if the low bit is + // zero, or nb_seq* if the low bit is one. void *foreign_bindings; #if defined(Py_LIMITED_API) PyObject* (*vectorcall)(PyObject *, PyObject * const*, size_t, PyObject *); diff --git a/include/nanobind/stl/unique_ptr.h b/include/nanobind/stl/unique_ptr.h index fac64047c..7186093a4 100644 --- a/include/nanobind/stl/unique_ptr.h +++ b/include/nanobind/stl/unique_ptr.h @@ -95,9 +95,8 @@ struct type_caster> { src = src_; // Don't accept foreign types; they can't relinquish ownership - if (!src.is_none() && !inst_check(src)) { + if (!src.is_none() && !inst_check(src)) return false; - } /* Try casting to a pointer of the underlying type. We pass flags=0 and cleanup=nullptr to prevent implicit type conversions (they are diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp index b2ecf2813..6c9a01288 100644 --- a/src/nb_foreign.cpp +++ b/src/nb_foreign.cpp @@ -20,7 +20,10 @@ NAMESPACE_BEGIN(detail) // nanobind exception translator that wraps a foreign one static void foreign_exception_translator(const std::exception_ptr &p, void *payload) { - ((pymb_framework *) payload)->translate_exception(&p); + std::exception_ptr e = p; + int translated = ((pymb_framework *) payload)->translate_exception(&e); + if (!translated) + std::rethrow_exception(e); } // When learning about a new foreign type, should we automatically use it? @@ -50,6 +53,8 @@ static void *nb_foreign_from_python(pymb_binding *binding, convert ? uint8_t(cast_flags::convert) : 0, keep_referenced ? &cleanup : nullptr, &result); if (keep_referenced) { + // Move temporary references from our `cleanup_list` to our caller's + // equivalent. for (uint32_t idx = 1; idx < cleanup.size(); ++idx) keep_referenced(keep_referenced_ctx, cleanup[idx]); if (cleanup.size() > 1) @@ -65,8 +70,12 @@ static PyObject *nb_foreign_to_python(pymb_binding *binding, cleanup_list cleanup{parent}; auto *td = (type_data *) binding->context; rv_policy rvp = (rv_policy) rvp_; - if (rvp > rv_policy::none) + if (rvp < rv_policy::take_ownership || rvp > rv_policy::none) { + // Future-proofing in case additional pymb_rv_policies are defined + // later: if we don't recognize this policy, then refuse the cast + // unless a pyobject wrapper already exists. rvp = rv_policy::none; + } return nb_type_put(td->type, cobj, rvp, &cleanup, nullptr); } @@ -85,8 +94,8 @@ static int nb_foreign_keep_alive(PyObject *nurse, } } -static void nb_foreign_translate_exception(const void *eptr) { - std::exception_ptr e = *(const std::exception_ptr *) eptr; +static int nb_foreign_translate_exception(void *eptr) noexcept { + std::exception_ptr &e = *(std::exception_ptr *) eptr; // Skip the default translator (at the end of the list). It translates // generic STL exceptions which other frameworks might want to translate @@ -102,7 +111,7 @@ static void nb_foreign_translate_exception(const void *eptr) { } try { cur->translator(e, cur->payload); - return; + return 1; } catch (...) { e = std::current_exception(); } } @@ -115,8 +124,8 @@ static void nb_foreign_translate_exception(const void *eptr) { if (!set_builtin_exception_status(e)) PyErr_SetString(PyExc_SystemError, "foreign function threw " "nanobind::next_overload()"); - } - // Anything not caught by the above bubbles out. + } catch (...) { e = std::current_exception(); } + return 0; } static void nb_foreign_add_foreign_binding(pymb_binding *binding) noexcept { @@ -188,17 +197,21 @@ static void nb_foreign_remove_foreign_binding(pymb_binding *binding) noexcept { static void nb_foreign_add_foreign_framework(pymb_framework *framework) noexcept { - if (framework->translate_exception) { + if (framework->translate_exception && + framework->abi_lang == pymb_abi_lang_cpp) { + decltype(&foreign_exception_translator) translator_to_use; { lock_internals guard{internals}; if (!internals->foreign_exception_translator) internals->foreign_exception_translator = foreign_exception_translator; + translator_to_use = internals->foreign_exception_translator; } - register_exception_translator(internals->foreign_exception_translator, + register_exception_translator(translator_to_use, framework, /*at_end=*/true); } - internals->print_leak_warnings &= !framework->bindings_usable_forever; + if (!(framework->flags & pymb_framework_leak_safe)) + internals->print_leak_warnings = false; } // (end of callbacks) @@ -215,11 +228,9 @@ static void register_with_pymetabind(nb_internals *internals_) { auto *fw = new pymb_framework{}; fw->name = "nanobind " NB_ABI_TAG; #if defined(NB_FREE_THREADED) - fw->bindings_usable_forever = 1; - fw->leak_safe = 0; + fw->flags = pymb_framework_bindings_usable_forever; #else - fw->bindings_usable_forever = 0; - fw->leak_safe = 1; + fw->flags = pymb_framework_leak_safe; #endif fw->abi_lang = pymb_abi_lang_cpp; fw->abi_extra = NB_PLATFORM_ABI_TAG; @@ -248,9 +259,8 @@ static void nb_type_import_binding(pymb_binding *binding, internals->foreign_imported_any = true; auto add_to_list = [binding](void *list_head) -> void* { - if (!list_head) { + if (!list_head) return binding; - } nb_foreign_seq *seq = nb_ensure_seq(&list_head); while (true) { if (seq->value == binding) diff --git a/src/nb_internals.cpp b/src/nb_internals.cpp index 678919c16..5544c3dab 100644 --- a/src/nb_internals.cpp +++ b/src/nb_internals.cpp @@ -300,7 +300,7 @@ static void internals_cleanup() { } if (p->foreign_self) { - pymb_list_unlink(&p->foreign_self->hook); + pymb_list_unlink(&p->foreign_self->link); delete p->foreign_self; } diff --git a/src/pymetabind.h b/src/pymetabind.h index d7bf2628b..e89390af0 100644 --- a/src/pymetabind.h +++ b/src/pymetabind.h @@ -8,8 +8,11 @@ * * This is version 0.1+dev of pymetabind. Changelog: * - * Unreleased: Fix typo in Py_GIL_DISABLED. Add pymb_framework::leak_safe. + * Unreleased: Use a bitmask for `pymb_framework::flags` and add leak_safe + * flag. Change `translate_exception` to be non-throwing. * Add casts from PyTypeObject* to PyObject* where needed. + * Fix typo in Py_GIL_DISABLED. Add noexcept to callback types. + * Rename `hook` -> `link` in linked list nodes. * * Version 0.1: Initial draft. ABI may change without warning while we * 2025-08-16 prove out the concept. Please wait for a 1.0 release @@ -66,7 +69,10 @@ #endif #if defined(__cplusplus) +#define PYMB_NOEXCEPT noexcept extern "C" { +#else +#define PYMB_NOEXCEPT #endif /* @@ -160,7 +166,7 @@ inline void pymb_list_append(struct pymb_list* list, #define PYMB_LIST_FOREACH(type, name, list) \ for (type name = (type) (list).head.next; \ name != (type) &(list).head; \ - name = (type) name->hook.next) + name = (type) name->link.next) /* * The registry holds information about all the interoperable binding @@ -212,6 +218,24 @@ inline void pymb_unlock_registry(struct pymb_registry*) {} struct pymb_binding; +/* Flags for a `pymb_framework` */ +enum pymb_framework_flags { + // Does this framework guarantee that its `pymb_binding` structures remain + // valid to use for the lifetime of the Python interpreter process once + // they have been linked into the lists in `pymb_registry`? Setting this + // flag reduces the number of atomic operations needed to work with + // this framework's bindings in free-threaded builds. + pymb_framework_bindings_usable_forever = 0x0001, + + // Does this framework reliably deallocate all of its type and function + // objects by the time the Python interpreter is finalized, in the absence + // of bugs in user code? If not, it might cause leaks of other frameworks' + // types or functions, via attributes or default argument values for + // this framework's leaked objects. Other frameworks can suppress their + // leak warnings (if so equipped) when a non-`leak_safe` framework is added. + pymb_framework_leak_safe = 0x0002, +}; + /* * Information about one framework that has registered itself with pymetabind. * "Framework" here refers to a set of bindings that are natively mutually @@ -229,7 +253,7 @@ struct pymb_binding; * and unmodified (except as documented below) until the Python interpreter * is finalized. After finalization, such as in a `Py_AtExit` handler, if * all bindings have been removed already, you may optionally clean up by - * calling `pymb_list_unlink(&framework->hook)` and then deallocating the + * calling `pymb_list_unlink(&framework->link)` and then deallocating the * `pymb_framework` structure. * * All fields of this structure are set before it is made visible to other @@ -238,27 +262,18 @@ struct pymb_binding; * individual documentation. */ struct pymb_framework { - // Hook by which this structure is linked into the list of + // Links to the previous and next framework in the list of // `pymb_registry::frameworks`. May be modified as other frameworks are // added; protected by the `pymb_registry::mutex` in free-threaded builds. - struct pymb_list_node hook; + struct pymb_list_node link; // Human-readable description of this framework, as a NUL-terminated string const char* name; - // Does this framework guarantee that its `pymb_binding` structures remain - // valid to use for the lifetime of the Python interpreter process once - // they have been linked into the lists in `pymb_registry`? Setting this - // to true reduces the number of atomic operations needed to work with - // this framework's bindings in free-threaded builds. - uint8_t bindings_usable_forever; - - // Does this framework reliably deallocate all of its type and function - // objects by the time the Python interpreter is finalized, in the absence - // of bugs in user code? If not, it might cause leaks of other frameworks' - // types or functions, via attributes or default argument values for - // this framework's leaked objects. - uint8_t leak_safe; + // Flags for this framework, a combination of `enum pymb_framework_flags`. + // Undefined flags must be set to zero to allow for future + // backward-compatible extensions. + uint16_t flags; // Reserved for future extensions. Set to 0. uint8_t reserved[2]; @@ -291,8 +306,8 @@ struct pymb_framework { // The function pointers below allow other frameworks to interact with // bindings provided by this framework. They are constant after construction - // and, except for `translate_exception()`, must not throw C++ exceptions. - // Unless otherwise documented, they must not be NULL. + // and must not throw C++ exceptions. Unless otherwise documented, + // they must not be NULL. // Extract a C/C++/etc object from `pyobj`. The desired type is specified by // providing a `pymb_binding*` for some binding that belongs to this @@ -312,7 +327,13 @@ struct pymb_framework { // `keep_referenced` should incref its `obj` immediately and remember // that it should be decref'ed later, for no net change in refcount. // This is an abstraction around something like the cleanup_list in - // nanobind or loader_life_support in pybind11. + // nanobind or loader_life_support in pybind11. The pointer returned by + // `from_python` may be invalidated once the `keep_referenced` references + // are dropped. If you're converting a function argument, you should keep + // any `keep_referenced` references alive until the function returns. + // If you're converting for some other purpose, you probably want to make + // a copy of the object to which `from_python`'s return value points before + // you drop the references. // // On free-threaded builds, callers must ensure that the `binding` is not // destroyed during a call to `from_python`. The requirements for this are @@ -321,7 +342,7 @@ struct pymb_framework { PyObject* pyobj, uint8_t convert, void (*keep_referenced)(void* ctx, PyObject* obj), - void* keep_referenced_ctx); + void* keep_referenced_ctx) PYMB_NOEXCEPT; // Wrap the C/C++/etc object `cobj` into a Python object using the given // return value policy. The type is specified by providing a `pymb_binding*` @@ -341,7 +362,7 @@ struct pymb_framework { PyObject* (*to_python)(struct pymb_binding* binding, void* cobj, enum pymb_rv_policy rvp, - PyObject* parent); + PyObject* parent) PYMB_NOEXCEPT; // Request that a PyObject reference be dropped, or that a callback // be invoked, when `nurse` is destroyed. `nurse` should be an object @@ -351,40 +372,44 @@ struct pymb_framework { // or -1 and sets the Python error indicator on error. // // No synchronization is required to call this method. - int (*keep_alive)(PyObject* nurse, void* payload, void (*cb)(void*)); - - // Attempt to translate a C++ exception known to this framework to Python. - // This should translate only framework-specific exceptions or user-defined - // exceptions that were registered with the framework, not generic - // ones such as `std::exception`. If successful, return normally with the - // Python error indicator set; otherwise, reraise the provided exception. - // `eptr` should be cast to `const std::exception_ptr* eptr` before use. - // This function pointer may be NULL if this framework does not provide - // C++ exception translation. + int (*keep_alive)(PyObject* nurse, + void* payload, + void (*cb)(void*)) PYMB_NOEXCEPT; + + // Attempt to translate the native exception `eptr` into a Python exception. + // If `abi_lang` is C++, then `eptr` should be cast to `std::exception_ptr*` + // before use; semantics for other languages have not been defined yet. This + // should translate only framework-specific exceptions or user-defined + // exceptions that were registered with the framework, not generic ones + // such as `std::exception`. If translation succeeds, return 1 with the + // Python error indicator set; otherwise, return 0. An exception may be + // converted into a different exception by modifying `*eptr` and returning + // zero. This function pointer may be NULL if this framework does not + // provide exception translation. // // No synchronization is required to call this method. - void (*translate_exception)(const void* eptr); + int (*translate_exception)(void* eptr) PYMB_NOEXCEPT; // Notify this framework that some other framework published a new binding. // This call will be made after the new binding has been linked into the // `pymb_registry::bindings` list. // // The `pymb_registry::mutex` or GIL will be held when calling this method. - void (*add_foreign_binding)(struct pymb_binding* binding); + void (*add_foreign_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; // Notify this framework that some other framework is about to remove // a binding. This call will be made after the binding has been removed // from the `pymb_registry::bindings` list. // // The `pymb_registry::mutex` or GIL will be held when calling this method. - void (*remove_foreign_binding)(struct pymb_binding* binding); + void (*remove_foreign_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; // Notify this framework that some other framework came into existence. // This call will be made after the new framework has been linked into the // `pymb_registry::frameworks` list and before it adds any bindings. // // The `pymb_registry::mutex` or GIL will be held when calling this method. - void (*add_foreign_framework)(struct pymb_framework* framework); + void (*add_foreign_framework)(struct pymb_framework* framework) PYMB_NOEXCEPT; // There is no remove_foreign_framework(); the interpreter has // already been finalized at that point, so there's nothing for the @@ -482,9 +507,9 @@ struct pymb_framework { * common than using them. */ struct pymb_binding { - // Hook by which this structure is linked into the list of + // Links to the previous and next bindings in the list of // `pymb_registry::bindings` - struct pymb_list_node hook; + struct pymb_list_node link; // The framework that provides this binding struct pymb_framework* framework; @@ -500,6 +525,9 @@ struct pymb_binding { // The way that this type would be written in `framework->abi_lang` source // code, as a NUL-terminated byte string without struct/class/enum words. + // If `framework->abi_lang` uses name mangling, this is the demangled, + // human-readable name. C++ users should note that the result of + // `typeid(x).name()` will need platform-specific alteration to produce one. // Examples: "Foo", "Bar::Baz", "std::vector >" const char* source_name; @@ -582,7 +610,7 @@ PYMB_FUNC struct pymb_registry* pymb_get_registry() { PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, struct pymb_framework* framework) { #if defined(Py_GIL_DISABLED) && PY_VERSION_HEX < 0x030e0000 - assert(framework->bindings_usable_forever && + assert((framework->flags & pymb_framework_bindings_usable_forever) && "Free-threaded removal of bindings requires PyUnstable_TryIncRef(), " "which was added in CPython 3.14"); #endif @@ -595,7 +623,7 @@ PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, break; } } - pymb_list_append(®istry->frameworks, &framework->hook); + pymb_list_append(®istry->frameworks, &framework->link); PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { if (other != framework) { other->add_foreign_framework(framework); @@ -628,7 +656,7 @@ PYMB_FUNC void pymb_add_binding(struct pymb_registry* registry, PyErr_WriteUnraisable((PyObject *) binding->pytype); } pymb_lock_registry(registry); - pymb_list_append(®istry->bindings, &binding->hook); + pymb_list_append(®istry->bindings, &binding->link); PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { if (other != binding->framework) { other->add_foreign_binding(binding); @@ -646,7 +674,7 @@ PYMB_FUNC void pymb_add_binding(struct pymb_registry* registry, PYMB_FUNC void pymb_remove_binding(struct pymb_registry* registry, struct pymb_binding* binding) { pymb_lock_registry(registry); - pymb_list_unlink(&binding->hook); + pymb_list_unlink(&binding->link); PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { if (other != binding->framework) { other->remove_foreign_binding(binding); @@ -662,7 +690,7 @@ PYMB_FUNC void pymb_remove_binding(struct pymb_registry* registry, */ PYMB_FUNC int pymb_try_ref_binding(struct pymb_binding* binding) { #if defined(Py_GIL_DISABLED) - if (!binding->framework->bindings_usable_forever) { + if (!(binding->framework->flags & pymb_framework_bindings_usable_forever)) { #if PY_VERSION_HEX >= 0x030e0000 return PyUnstable_TryIncRef((PyObject *) binding->pytype); #else @@ -680,7 +708,7 @@ PYMB_FUNC int pymb_try_ref_binding(struct pymb_binding* binding) { /* Decrease the reference count of a binding. */ PYMB_FUNC void pymb_unref_binding(struct pymb_binding* binding) { #if defined(Py_GIL_DISABLED) - if (!binding->framework->bindings_usable_forever) { + if (!(binding->framework->flags & pymb_framework_bindings_usable_forever)) { #if PY_VERSION_HEX >= 0x030e0000 Py_DECREF((PyObject *) binding->pytype); #else From 54c5e11f6c48f4c8625c0df36efbec12a763a77f Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Tue, 9 Sep 2025 18:14:08 -0600 Subject: [PATCH 5/8] First big round of updates - Update pymetabind - Complete nanobind documentation of the new feature - Change "foreign" to "interop" in some places so that the word "foreign" is more consistently used for the other framework rather than the information exchange between them - Allow enum types to participate in interop - Allow nanobind to register implicit conversions from foreign types to nanobind types --- cmake/nanobind-config.cmake | 2 +- docs/api_cmake.rst | 6 +- docs/api_core.rst | 91 +++++++++++++++++++++++ docs/changelog.rst | 20 ++++- docs/faq.rst | 94 +++++++++++++++++------ docs/index.rst | 1 + docs/refleaks.rst | 4 +- include/nanobind/nb_cast.h | 9 ++- include/nanobind/nb_class.h | 15 ++-- include/nanobind/nb_lib.h | 10 ++- src/nb_enum.cpp | 68 ++++++++++++++--- src/nb_foreign.cpp | 39 +++++++++- src/nb_func.cpp | 2 +- src/nb_internals.h | 18 ++++- src/nb_type.cpp | 144 +++++++++++++++++++----------------- 15 files changed, 396 insertions(+), 127 deletions(-) diff --git a/cmake/nanobind-config.cmake b/cmake/nanobind-config.cmake index 63a98ca91..7c62e7250 100644 --- a/cmake/nanobind-config.cmake +++ b/cmake/nanobind-config.cmake @@ -238,7 +238,7 @@ function (nanobind_build_library TARGET_NAME) endif() if (TARGET_NAME MATCHES "-local") - target_compile_definitions(${TARGET_NAME} PRIVATE NB_DISABLE_FOREIGN) + target_compile_definitions(${TARGET_NAME} PRIVATE NB_DISABLE_INTEROP) endif() # Nanobind performs many assertion checks -- detailed error messages aren't diff --git a/docs/api_cmake.rst b/docs/api_cmake.rst index 486ad6eec..070576bf8 100644 --- a/docs/api_cmake.rst +++ b/docs/api_cmake.rst @@ -111,9 +111,9 @@ The high-level interface consists of just one CMake command: If this explanation sounds confusing, then you can ignore it. See the detailed description below for more information on this step. * - ``NO_INTEROP`` - - Remove support for interoperability with other Python binding - frameworks. If you don't need it in your environment, this offers - a minor performance and code size benefit. + - Remove support for :ref:`interoperability with other Python binding + frameworks `. If you don't need it in your environment, this + offers a minor performance and code size benefit. :cmake:command:`nanobind_add_module` performs the following steps to produce bindings. diff --git a/docs/api_core.rst b/docs/api_core.rst index 0323c60b8..679b70dee 100644 --- a/docs/api_core.rst +++ b/docs/api_core.rst @@ -3130,12 +3130,24 @@ Global flags functions are still alive when the Python interpreter shuts down. Call this function to disable or re-enable leak warnings. + The configuration affected by this function is global to a :ref:`nanobind + domain `. If you use it, you are encouraged to isolate your + extension from others by passing the ``NB_DOMAIN`` parameter to the + :cmake:command:`nanobind_add_module()` CMake command, so that your choices + don't need to impact unrelated extensions. + .. cpp:function:: void set_implicit_cast_warnings(bool value) noexcept By default, nanobind loudly complains when it attempts to perform an implicit conversion, and when that conversion is not successful. Call this function to disable or re-enable the warnings. + The configuration affected by this function is global to a :ref:`nanobind + domain `. If you use it, you are encouraged to isolate your + extension from others by passing the ``NB_DOMAIN`` parameter to the + :cmake:command:`nanobind_add_module()` CMake command, so that your choices + don't need to impact unrelated extensions. + .. cpp:function:: inline bool is_alive() noexcept The function returns ``true`` when nanobind is initialized and ready for @@ -3144,6 +3156,85 @@ Global flags this liveness status can be useful to avoid operations that are illegal in the latter context. +Interoperability +---------------- + +See the :ref:`separate interoperability documentation ` for important +additional information and caveats about this feature. + +.. cpp:function:: void interoperate_by_default(bool export_all = true, bool import_all = true) + + Configure whether this :ref:`nanobind domain ` should exchange + type information with other binding libraries (and other nanobind domains) + so that functions bound in one can accept and return objects whose types are + bound in another. The default, if :cpp:func:`interoperate_by_default` is not + called, is to exchange such information only when requested on a per-type + basis via calls to :cpp:func:`import_for_interop` or + :cpp:func:`export_for_interop`. + + By specifying arguments to :cpp:func:`interoperate_by_default`, it is + possible to separately configure whether to enable automatic sharing of + our types with others (*export_all*) or automatic use of types shared by + others (*import_all*). Once either type of automatic sharing is enabled, + it remains enabled for the lifetime of the Python interpreter. + + Automatic export is equivalent to calling :cpp:func:`export_for_interop` on + each type produced by a :cpp:class:`nb::class_` or :cpp:struct:`nb::enum_` + binding statement in this nanobind domain. + + Automatic import is equivalent to calling :cpp:func:`import_for_interop` on + each type exported by a different binding library or nanobind domain, as + long as that library is written in C++ and uses a compatible platform ABI. + In order to interoperate with a type binding that was created in another + language, including pure C, you must still make an explicit call to + :cpp:func:`import_for_interop`, providing a template argument so that + nanobind can tell which C++ type should be associated with it. + + Enabling automatic export or import affects both types that have already been + bound and types that are yet to be bound. + + The configuration affected by this function is global to a nanobind + domain. If you use it, you are encouraged to isolate your extension from + others by passing the ``NB_DOMAIN`` parameter to the + :cmake:command:`nanobind_add_module()` CMake command, so that your choices + don't need to impact unrelated extensions. + +.. cpp:function:: template void import_for_interop(handle type) + + Make the Python type object *type*, which was bound using a different binding + framework (or different nanobind domain) and then exported using a facility + like our :cpp:func:`export_for_interop`, be available so that functions bound + in this nanobind domain can accept and return its instances using the + C++ type ``T``, or the C++ type that was specified when *type* was bound. + + If no template argument is specified, then *type* must have been bound by + another C++ binding library that uses the same :ref:`platform ABI + ` as us; the C++ type will be determined by inspecting its + binding. + + If a template argument is specified, then nanobind associates that C++ type + with the given Python type, trusting rather than verifying that they match. + The C++ type ``T`` must perfectly match the memory layout and ABI of the + structure used by whoever bound the Python *type*. + This is the only way to import types written in other languages than C++ + (including those in plain C), since nanobind needs a ``std::type_info`` for + its type lookups and non-C++ bindings don't provide those directly. + + Repeatedly importing the same Python type as the same C++ type is idempotent. + Importing the same Python type as multiple different C++ types will fail. + + A descriptive C++ exception will be thrown if the import fails. + +.. cpp:function:: void export_for_interop(handle type) + + Make the Python type object *type*, which was bound in this nanobind domain, + be available for import by other binding libraries and other nanobind + domains. If they do so, then their bound functions will be able to accept + and return instances of *type*. + + Repeatedly exporting the same type is idempotent. + + Miscellaneous ------------- diff --git a/docs/changelog.rst b/docs/changelog.rst index 83c1d0eec..c0518cfef 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,24 @@ case, both modules must use the same nanobind ABI version, or they will be isolated from each other. Releases that don't explicitly mention an ABI version below inherit that of the preceding release. +Version TBD (unreleased) +------------------------ + +.. TODO: update the pybind11 version number below before releasing + +- nanobind has adopted the new `pymetabind + `__ standard for interoperating + with other Python binding libraries (including other ABI versions of + nanobind). When the interoperability feature is activated, which in most cases + is as simple as writing :cpp:func:`nb::interoperate_by_default() + `, a function or method that is bound using nanobind + can accept and return values of types that were bound using other binding + libraries that support pymetabind, notably including pybind11 versions !TBD! + and later. This feature is likely to be of **great utility** to anyone who + is working on porting large or interconnected extension modules from pybind11 + to nanobind. See the extensive :ref:`interoperability documentation ` + for more details. + Version 2.8.0 (July 16, 2025) ----------------------------- @@ -295,7 +313,7 @@ Version 2.3.0 There is no version 2.3.0 due to a deployment mishap. -- Added casters for `Eigen::Map` types from the `Eigen library +- Added casters for ``Eigen::Map`` types from the `Eigen library `__. (PR `#782 `_). diff --git a/docs/faq.rst b/docs/faq.rst index e37c807bf..4981c97c0 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -233,33 +233,35 @@ will: .. _type-visibility: -How can I avoid conflicts with other projects using nanobind? -------------------------------------------------------------- - -Suppose that a type binding in your project conflicts with another extension, for -example because both expose a common type (e.g., ``std::latch``). nanobind will -warn whenever it detects such a conflict: +How can I control whether two extension modules see each other's types? +----------------------------------------------------------------------- + +nanobind creates a variety of internal data structures to support the bindings +you ask it to make. These are not isolated to a single extension module, +because it's useful for large binding projects to be able to split related +bindings into multiple extensions without losing their ability to work with +one another's types. Instead, extension modules are divided into nanobind +*domains* based on two attributes: an automatically determined string (the +nanobind "ABI tag") that captures compatibility-relevant aspects of their +build environments and nanobind versions, and an ``NB_DOMAIN`` string that +may be provided at build time (as shown below). +If multiple extension modules share the same ABI tag and the same ``NB_DOMAIN`` +string, they will wind up in the same nanobind domain, which allows them to +work together exactly as if they were one big extension. + +Sometimes, this causes problems. For example, you might expose a binding +for a commonly used type (such as ``std::latch``) that some other nanobind +extension in your Python interpreter also happens to provide a binding for. +nanobind will warn whenever it detects such a conflict: .. code-block:: text RuntimeWarning: nanobind: type 'latch' was already registered! In the worst case, this could actually break both packages (especially if the -bindings of the two packages expose an inconsistent/incompatible API). - -The higher-level issue here is that nanobind will by default try to make type -bindings visible across extensions because this is helpful to partition large -binding projects into smaller parts. Such information exchange requires that -the extensions: - -- use the same nanobind *ABI version* (see the :ref:`Changelog ` for details). -- use the same compiler (extensions built with GCC and Clang are isolated from each other). -- use ABI-compatible versions of the C++ library. -- use the stable ABI interface consistently (stable and unstable builds are isolated from each other). -- use debug/release mode consistently (debug and release builds are isolated from each other). - -In addition, nanobind provides a feature to intentionally scope extensions to a -named domain to avoid conflicts with other extensions. To do so, specify the +bindings of the two packages expose an inconsistent/incompatible API). So it's +useful to be able to enforce a boundary between extensions sometimes, even if +they would otherwise be ABI-compatible. To do so, you can specify the ``NB_DOMAIN`` parameter in CMake: .. code-block:: cmake @@ -268,8 +270,54 @@ named domain to avoid conflicts with other extensions. To do so, specify the NB_DOMAIN my_project my_ext.cpp) -In this case, inter-extension type visibility is furthermore restricted to -extensions in the ``"my_project"`` domain. +Two extensions can only be in the same nanobind domain if either they both +specify the same value for that parameter (``"my_project"`` in this case) or +neither one specifies the parameter. + +As mentioned above, two extensions can also only be in the same nanobind domain +if they share the same ABI tag. This is determined in two parts, as follows: + +- They must use compatible *platform ABI*, so that (for example) a + ``std::vector`` created in one can be safely used in the other. + That means: + + - They must use the same C++ standard library (MSVC, libc++, or libstdc++), + and the same ABI version of it. For example, extensions that use libstdc++ + must match in terms of whether they use the pre- or post-C++11 ABI, and + extensions that use libc++ must use the same `libc++ ABI version + `__. + + - On Windows, they must use the same compiler (MSVC, mingw, or cygwin). + + - If compiled using MSVC, they must use the same major version of the + compiler, the same multi-threading style (dynamic ``/MD``, + static ``/MT``, or single-threaded), and they must match in terms of + whether or not they are built in debugging mode. + +- They must use compatible *nanobind ABI*, so that the nanobind internal data + structures created in one can be safely used in the other. That means: + + - They must use the same nanobind *ABI version*; see the + :ref:`Changelog ` for details. + + - They must be consistent in their use of Python's stable ABI: either + both built against the stable ABI (cmake ``STABLE_ABI`` flag) or both not. + + - They must be consistent in their use of free-threading: either both + built with free-threading support (cmake ``FREE_THREADED`` flag) or both not. + + - They must either both use released versions of nanobind or both be built + from Git development snapshots, rather than a mix of the two. + +If you want to share some types between two extensions that have the same +platform ABI (the first category in the above list), but are in different +nanobind domains due to using different nanobind ABI or different specified +``NB_DOMAIN`` strings, all is not lost! The :ref:`interoperability support +` between nanobind and other binding libraries also provides +interoperability between different nanobind domains, as long as the platform +ABI matches. It must be specifically enabled, and there are a few things it +can't do, but for most purposes it's hard to tell the difference from operating +within the same domain. See the linked documentation for more details. Can I use nanobind without RTTI or C++ exceptions? -------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 53a7dc42a..bb289e2dc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -128,6 +128,7 @@ The nanobind logo was designed by `AndoTwin Studio packaging typing utilities + interop .. toctree:: :caption: Advanced diff --git a/docs/refleaks.rst b/docs/refleaks.rst index 1624fa8d3..96e784df4 100644 --- a/docs/refleaks.rst +++ b/docs/refleaks.rst @@ -58,8 +58,8 @@ easily disable them by calling :cpp:func:`nb::set_leak_warnings() } Note that is a *global flag* shared by all nanobind extension libraries in the -same ABI domain. When changing global flags, please isolate your extension from -others by passing the ``NB_DOMAIN`` parameter to the +same `nanobind domain `. When changing global flags, please +isolate your extension from others by passing the ``NB_DOMAIN`` parameter to the :cmake:command:`nanobind_add_module()` CMake command: .. code-block:: cmake diff --git a/include/nanobind/nb_cast.h b/include/nanobind/nb_cast.h index a0781df4a..9b2ceb58b 100644 --- a/include/nanobind/nb_cast.h +++ b/include/nanobind/nb_cast.h @@ -189,15 +189,16 @@ struct type_caster && !is_std_char_v>> template struct type_caster>> { - NB_INLINE bool from_python(handle src, uint8_t flags, cleanup_list *) noexcept { + NB_INLINE bool from_python(handle src, uint8_t flags, cleanup_list *cleanup) noexcept { int64_t result; - bool rv = enum_from_python(&typeid(T), src.ptr(), &result, flags); + bool rv = enum_from_python(&typeid(T), src.ptr(), &result, sizeof(T), + flags, cleanup); value = (T) result; return rv; } NB_INLINE static handle from_cpp(T src, rv_policy, cleanup_list *) noexcept { - return enum_from_cpp(&typeid(T), (int64_t) src); + return enum_from_cpp(&typeid(T), (int64_t) src, sizeof(T)); } NB_TYPE_CASTER(T, const_name()) @@ -504,6 +505,8 @@ template struct type_caster_base : type_caster_base_tag { } else { const std::type_info *type_p = (!has_type_hook && ptr) ? &typeid(*ptr) : nullptr; + if (type_p && (void *) ptr != dynamic_cast(ptr)) + type_p = nullptr; // don't try to downcast from a non-primary base return nb_type_put_p(type, type_p, ptr, policy, cleanup); } } diff --git a/include/nanobind/nb_class.h b/include/nanobind/nb_class.h index 8840585f2..7aa6b980b 100644 --- a/include/nanobind/nb_class.h +++ b/include/nanobind/nb_class.h @@ -106,8 +106,8 @@ struct enum_tbl_t { /// Information about a type that persists throughout its lifetime struct type_data { uint32_t size; - uint32_t align : 8; - uint32_t flags : 24; + uint32_t align : 8; // always zero for an enum + uint32_t flags : 24; // type_flags or enum_flags const char *name; const std::type_info *type; PyTypeObject *type_py; @@ -212,6 +212,7 @@ struct enum_init_data { PyObject *scope; const char *name; const char *docstr; + uint32_t size; uint32_t flags; }; @@ -333,15 +334,16 @@ inline void *type_get_slot(handle h, int slot_id) { } // nanobind interoperability with other binding frameworks -inline void set_foreign_type_defaults(bool export_all, bool import_all) { - detail::nb_type_set_foreign_defaults(export_all, import_all); +inline void interoperate_by_default(bool export_all = true, + bool import_all = true) { + detail::nb_type_set_interop_defaults(export_all, import_all); } template -inline void import_foreign_type(handle type) { +inline void import_for_interop(handle type) { detail::nb_type_import(type.ptr(), std::is_void_v ? nullptr : &typeid(T)); } -inline void export_type_to_foreign(handle type) { +inline void export_for_interop(handle type) { detail::nb_type_export(type.ptr()); } @@ -787,6 +789,7 @@ template class enum_ : public object { NB_INLINE enum_(handle scope, const char *name, const Extra &... extra) { detail::enum_init_data ed { }; ed.type = &typeid(T); + ed.size = sizeof(T); ed.scope = scope.ptr(); ed.name = name; ed.flags = std::is_signed_v diff --git a/include/nanobind/nb_lib.h b/include/nanobind/nb_lib.h index 7a5ce4a12..83e619955 100644 --- a/include/nanobind/nb_lib.h +++ b/include/nanobind/nb_lib.h @@ -389,7 +389,7 @@ NB_CORE void nb_inst_set_state(PyObject *o, bool ready, bool destruct) noexcept; NB_CORE std::pair nb_inst_state(PyObject *o) noexcept; // Set whether types will be shared with other binding frameworks by default -NB_CORE void nb_type_set_foreign_defaults(bool export_all, bool import_all); +NB_CORE void nb_type_set_interop_defaults(bool export_all, bool import_all); // Teach nanobind about a type bound by another binding framework NB_CORE void nb_type_import(PyObject *pytype, const std::type_info *cpptype); @@ -446,11 +446,13 @@ NB_CORE void enum_append(PyObject *tp, const char *name, int64_t value, const char *doc) noexcept; // Query an enumeration's Python object -> integer value map -NB_CORE bool enum_from_python(const std::type_info *, PyObject *, int64_t *, - uint8_t flags) noexcept; +NB_CORE bool enum_from_python(const std::type_info *tp, PyObject *obj, + int64_t *out, uint32_t enum_width, + uint8_t flags, cleanup_list *cleanup) noexcept; // Query an enumeration's integer value -> Python object map -NB_CORE PyObject *enum_from_cpp(const std::type_info *, int64_t) noexcept; +NB_CORE PyObject *enum_from_cpp(const std::type_info *tp, + int64_t key, uint32_t enum_width) noexcept; /// Export enum entries to the parent scope NB_CORE void enum_export(PyObject *tp); diff --git a/src/nb_enum.cpp b/src/nb_enum.cpp index 1319d998c..65656c550 100644 --- a/src/nb_enum.cpp +++ b/src/nb_enum.cpp @@ -167,10 +167,44 @@ void enum_append(PyObject *tp_, const char *name_, int64_t value_, rev->emplace((int64_t) (uintptr_t) el.ptr(), value_); } -bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8_t flags) noexcept { - type_data *t = nb_type_c2p(internals, tp); - if (!t) +bool enum_from_python(const std::type_info *tp, + PyObject *o, + int64_t *out, + uint32_t enum_width, + uint8_t flags, + cleanup_list *cleanup) noexcept { + bool has_foreign = false; + +#if !defined(NB_DISABLE_INTEROP) + auto try_foreign = [=, &has_foreign]() -> bool { + if (has_foreign) { + void *ptr = nb_type_get_foreign(internals, tp, o, flags, cleanup); + if (ptr) { + // Copy from the C++ enum object to our output integer. + // We don't bother sign-extending because the enum type caster + // is just going to truncate back down to `out_width` + // immediately. (For a signed destination type, the behavior + // was formally implementation-defined before C++20, but all + // known implementations behaved as described.) + switch (enum_width) { + case 1: *out = *(uint8_t *) ptr; return true; + case 2: *out = *(uint16_t *) ptr; return true; + case 4: *out = *(uint32_t *) ptr; return true; + case 8: *out = *(uint64_t *) ptr; return true; + default: return false; + } + return true; + } + } return false; + }; +#else + auto try_foreign = []() { return nullptr; }; +#endif + + type_data *t = nb_type_c2p(internals, tp, &has_foreign); + if (!t) + return try_foreign(); if ((t->flags & (uint32_t) enum_flags::is_flag) != 0 && Py_TYPE(o) == t->type_py) { PyObject *value_o = PyObject_GetAttrString(o, "value"); @@ -212,7 +246,7 @@ bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8 long long value = PyLong_AsLongLong(o); if (value == -1 && PyErr_Occurred()) { PyErr_Clear(); - return false; + return try_foreign(); } enum_map::iterator it2 = fwd->find((int64_t) value); if (it2 != fwd->end()) { @@ -223,7 +257,7 @@ bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8 unsigned long long value = PyLong_AsUnsignedLongLong(o); if (value == (unsigned long long) -1 && PyErr_Occurred()) { PyErr_Clear(); - return false; + return try_foreign(); } enum_map::iterator it2 = fwd->find((int64_t) value); if (it2 != fwd->end()) { @@ -234,13 +268,29 @@ bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8 } - return false; + return try_foreign(); } -PyObject *enum_from_cpp(const std::type_info *tp, int64_t key) noexcept { - type_data *t = nb_type_c2p(internals, tp); - if (!t) +PyObject *enum_from_cpp(const std::type_info *tp, + int64_t key, + uint32_t enum_width) noexcept { + bool has_foreign = false; + type_data *t = nb_type_c2p(internals, tp, &has_foreign); + if (!t) { +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign) { +#if PY_BIG_ENDIAN + // Adjust key so it can be safely reinterpreted as a smaller integer + key >>= 8 * (8 - enum_width); +#else + (void) enum_width; +#endif + return nb_type_put_foreign(internals, tp, nullptr, &key, + rv_policy::copy, nullptr, nullptr); + } +#endif return nullptr; + } enum_map *fwd = (enum_map *) t->enum_tbl.fwd; diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp index 6c9a01288..d27459846 100644 --- a/src/nb_foreign.cpp +++ b/src/nb_foreign.cpp @@ -8,7 +8,7 @@ BSD-style license that can be found in the LICENSE file. */ -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) #include "nb_internals.h" #include "nb_ft.h" @@ -48,6 +48,20 @@ static void *nb_foreign_from_python(pymb_binding *binding, void *keep_referenced_ctx) noexcept { cleanup_list cleanup{nullptr}; auto *td = (type_data *) binding->context; + if (td->align == 0) { // enum + int64_t value; + if (keep_referenced && + enum_from_python(td->type, pyobj, &value, td->size, + convert ? uint8_t(cast_flags::convert) : 0, + nullptr)) { + bytes holder{(uint8_t *) &value + PY_BIG_ENDIAN * (8 - td->size), + td->size}; + keep_referenced(keep_referenced_ctx, holder.ptr()); + return (void *) holder.data(); + } + return nullptr; + } + void *result = nullptr; bool ok = nb_type_get(td->type, pyobj, convert ? uint8_t(cast_flags::convert) : 0, @@ -69,6 +83,25 @@ static PyObject *nb_foreign_to_python(pymb_binding *binding, PyObject *parent) noexcept { cleanup_list cleanup{parent}; auto *td = (type_data *) binding->context; + if (td->align == 0) { // enum + int64_t key; + switch (td->size) { + case 1: key = *(uint8_t *) cobj; break; + case 2: key = *(uint16_t *) cobj; break; + case 4: key = *(uint32_t *) cobj; break; + case 8: key = *(uint64_t *) cobj; break; + default: return nullptr; + } + if (rvp_ == pymb_rv_policy_take_ownership) + ::operator delete(cobj); + if ((td->flags & (uint32_t) enum_flags::is_signed) && td->size < 8) { + // sign extend + key <<= (64 - (td->size * 8)); + key >>= (64 - (td->size * 8)); + } + return enum_from_cpp(td->type, key, td->size); + } + rv_policy rvp = (rv_policy) rvp_; if (rvp < rv_policy::take_ownership || rvp > rv_policy::none) { // Future-proofing in case additional pymb_rv_policies are defined @@ -434,7 +467,7 @@ void *nb_type_try_foreign(nb_internals *internals_, const std::type_info *type, void* (*attempt)(void *closure, pymb_binding *binding), - void *closure) { + void *closure) noexcept { // It is not valid to reuse the lookup made by a previous nb_type_c2p(), // because some bindings could have been removed between then and now. #if defined(NB_FREE_THREADED) @@ -548,4 +581,4 @@ void *nb_type_try_foreign(nb_internals *internals_, NAMESPACE_END(detail) NAMESPACE_END(NB_NAMESPACE) -#endif /* !defined(NB_DISABLE_FOREIGN) */ +#endif /* !defined(NB_DISABLE_INTEROP) */ diff --git a/src/nb_func.cpp b/src/nb_func.cpp index 2c60c0166..2dad4f074 100644 --- a/src/nb_func.cpp +++ b/src/nb_func.cpp @@ -1308,7 +1308,7 @@ static uint32_t nb_func_render_signature(const func_data *f, auto it = internals_->type_c2p_slow.find(*descr_type); if (it != internals_->type_c2p_slow.end()) { object ty; -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (nb_is_foreign(it->second)) { void *bindings = nb_get_foreign(it->second); pymb_binding *binding = diff --git a/src/nb_internals.h b/src/nb_internals.h index 6378fda63..39caf1663 100644 --- a/src/nb_internals.h +++ b/src/nb_internals.h @@ -194,7 +194,7 @@ nb_seq* nb_ensure_seq(void **p) { /// or (pymb_binding*) nb_get_foreign(p) depending on the value of /// nb_is_seq(nb_get_foreign(p)). -#if defined(NB_DISABLE_FOREIGN) +#if defined(NB_DISABLE_INTEROP) NB_INLINE bool nb_is_foreign(void *) { return false; } #else NB_INLINE bool nb_is_foreign(void *p) { return ((uintptr_t) p) & 2; } @@ -752,12 +752,24 @@ extern nb_type_map_per_thread::guard nb_type_lock_c2p_fast( extern void nb_type_update_c2p_fast(const std::type_info *type, void *value) noexcept; -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) extern void *nb_type_try_foreign(nb_internals *internals_, const std::type_info *type, void* (*attempt)(void *closure, pymb_binding *binding), - void *closure); + void *closure) noexcept; +extern void *nb_type_get_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + PyObject *src, + uint8_t flags, + cleanup_list *cleanup) noexcept; +extern PyObject *nb_type_put_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + const std::type_info *cpp_type_p, + void *value, + rv_policy rvp, + cleanup_list *cleanup, + bool *is_new) noexcept; extern void nb_type_import_impl(PyObject *pytype, const std::type_info *cpptype); extern void nb_type_export_impl(type_data *td); diff --git a/src/nb_type.cpp b/src/nb_type.cpp index b84585651..cb4586840 100644 --- a/src/nb_type.cpp +++ b/src/nb_type.cpp @@ -426,7 +426,7 @@ type_data *nb_type_c2p(nb_internals *internals_, auto *t = (type_data *) result; -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (nb_is_foreign(result)) { if (has_foreign) *has_foreign = true; @@ -494,7 +494,7 @@ bool nb_type_register(type_data *t, type_data **conflict) noexcept { lock_internals guard(internals_); auto [it_slow, inserted] = type_c2p_slow.try_emplace(t->type, t); if (!inserted) { -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (nb_is_foreign(it_slow->second)) { nb_store_release(t->foreign_bindings, nb_get_foreign(it_slow->second)); @@ -507,7 +507,7 @@ bool nb_type_register(type_data *t, type_data **conflict) noexcept { } } nb_type_update_c2p_fast(t->type, t); -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (internals_->foreign_export_all) nb_type_export_impl(t); #endif @@ -523,7 +523,7 @@ void nb_type_unregister(type_data *t) noexcept { check(it_slow != type_c2p_slow.end() && it_slow->second == t, "nanobind::detail::nb_type_unregister(\"%s\"): could not " "find type!", t->name); -#if defined(NB_DISABLE_FOREIGN) +#if defined(NB_DISABLE_INTEROP) type_c2p_slow.erase(it_slow); nb_type_update_c2p_fast(t->type, nullptr); #else @@ -1538,8 +1538,8 @@ PyObject *call_one_arg(PyObject *fn, PyObject *arg) noexcept { static NB_NOINLINE bool nb_type_get_implicit(PyObject *src, const std::type_info *cpp_type_src, const type_data *dst_type, - nb_internals *internals_, - cleanup_list *cleanup, void **out) noexcept { + cleanup_list *cleanup, + void **out) noexcept { if (dst_type->implicit.cpp && cpp_type_src) { const std::type_info **it = dst_type->implicit.cpp; const std::type_info *v; @@ -1551,8 +1551,9 @@ static NB_NOINLINE bool nb_type_get_implicit(PyObject *src, it = dst_type->implicit.cpp; while ((v = *it++)) { - const type_data *d = nb_type_c2p(internals_, v); - if (d && PyType_IsSubtype(Py_TYPE(src), d->type_py)) + PyTypeObject *pytype = + (PyTypeObject *) nb_type_lookup(v, /* foreign_ok */ true); + if (pytype && PyType_IsSubtype(Py_TYPE(src), pytype)) goto found; } } @@ -1601,6 +1602,37 @@ static NB_NOINLINE bool nb_type_get_implicit(PyObject *src, } } +#if !defined(NB_DISABLE_INTEROP) +// Encapsulates the foreign-type part of nb_type_get() +void *nb_type_get_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + PyObject *src, + uint8_t flags, + cleanup_list *cleanup) noexcept { + struct capture { + PyObject *src; + uint8_t flags; + cleanup_list *cleanup; + } cap{src, flags, cleanup}; + + auto attempt = +[](void *closure, pymb_binding *binding) -> void* { + capture &cap = *(capture *) closure; + auto keep_referenced = [](void *ctx, PyObject* item) { + Py_INCREF(item); + ((cleanup_list *) ctx)->append(item); + }; + return binding->framework->from_python( + binding, + cap.src, + bool(cap.flags & (uint16_t) cast_flags::convert), + cap.cleanup ? +keep_referenced : nullptr, + cap.cleanup); + }; + + return nb_type_try_foreign(internals_, cpp_type, attempt, &cap); +} +#endif + // Attempt to retrieve a pointer to a C++ instance bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, cleanup_list *cleanup, void **out) noexcept { @@ -1632,16 +1664,10 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, if (dst_type) valid = PyType_IsSubtype(src_type, dst_type->type_py); } else { + // The input is a nanobind instance of the correct type, + // so there's no need to check foreign types. Thus, it's + // safe to leave has_foreign == false in this branch. dst_type = t; -#if !defined(NB_DISABLE_FOREIGN) - // See comment at the end of nb_type_c2p - void *foreign_bindings = t ? nb_load_acquire(t->foreign_bindings) - : nullptr; - has_foreign = t && foreign_bindings && - (nb_is_seq(foreign_bindings) || - ((pymb_binding *) foreign_bindings)->framework != - internals_->foreign_self); -#endif } // Success, return the pointer if the instance is correctly initialized @@ -1687,8 +1713,7 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, if (dst_type && (dst_type->flags & (uint32_t) type_flags::has_implicit_conversions) && - nb_type_get_implicit(src, cpp_type_src, dst_type, internals_, - cleanup, out)) + nb_type_get_implicit(src, cpp_type_src, dst_type, cleanup, out)) return true; } else if (!src_is_nb_type && internals_->foreign_imported_any) { // If we never determined the dst type and it might be foreign, @@ -1696,30 +1721,11 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, (void) nb_type_c2p(internals_, cpp_type, &has_foreign); } -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) // Try a foreign type if (has_foreign) { - struct capture { - PyObject *src; - uint8_t flags; - cleanup_list *cleanup; - } cap{src, flags, cleanup}; - - auto attempt = +[](void *closure, pymb_binding *binding) -> void* { - capture &cap = *(capture *) closure; - auto keep_referenced = [](void *ctx, PyObject* item) { - Py_INCREF(item); - ((cleanup_list *) ctx)->append(item); - }; - return binding->framework->from_python( - binding, - cap.src, - bool(cap.flags & (uint16_t) cast_flags::convert), - cap.cleanup ? +keep_referenced : nullptr, - cap.cleanup); - }; - - void *result = nb_type_try_foreign(internals_, cpp_type, attempt, &cap); + void *result = nb_type_get_foreign( + internals_, cpp_type, src, flags, cleanup); if (result) { *out = result; return true; @@ -1786,7 +1792,7 @@ void keep_alive(PyObject *nurse, PyObject *patient) { if (!weakref) { Py_DECREF(callback); PyErr_Clear(); -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (pymb_binding *binding = pymb_get_binding(nurse)) { // Try a foreign framework's keep_alive as a last resort if (binding->framework->keep_alive(nurse, patient, @@ -1929,13 +1935,14 @@ static PyObject *nb_type_put_common(void *value, type_data *t, rv_policy rvp, return (PyObject *) inst; } -#if !defined(NB_DISABLE_FOREIGN) -static PyObject *nb_type_put_foreign(nb_internals *internals_, - const std::type_info *cpp_type, - const std::type_info *cpp_type_p, - void *value, rv_policy rvp, - cleanup_list *cleanup, - bool *is_new) noexcept { +#if !defined(NB_DISABLE_INTEROP) +PyObject *nb_type_put_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + const std::type_info *cpp_type_p, + void *value, + rv_policy rvp, + cleanup_list *cleanup, + bool *is_new) noexcept { struct capture { void *value; rv_policy rvp; @@ -1995,7 +2002,7 @@ PyObject *nb_type_put(const std::type_info *cpp_type, return true; }; -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) auto try_foreign = [=, &has_foreign]() -> PyObject* { if (has_foreign) return nb_type_put_foreign(internals_, cpp_type, nullptr, value, @@ -2047,7 +2054,7 @@ PyObject *nb_type_put(const std::type_info *cpp_type, seq = *seq.next; } } else if (rvp == rv_policy::none) { -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (internals_->foreign_imported_any) { (void) lookup_type(); return try_foreign(); @@ -2066,7 +2073,8 @@ PyObject *nb_type_put(const std::type_info *cpp_type, PyObject *nb_type_put_p(const std::type_info *cpp_type, const std::type_info *cpp_type_p, - void *value, rv_policy rvp, + void *value, + rv_policy rvp, cleanup_list *cleanup, bool *is_new) noexcept { // Convert nullptr -> None @@ -2096,7 +2104,7 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, return true; }; -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) auto try_foreign = [=, &has_foreign]() -> PyObject* { if (has_foreign) { if (cpp_type_p && cpp_type_p != cpp_type) { @@ -2158,7 +2166,7 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, seq = *seq.next; } } else if (rvp == rv_policy::none) { -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (internals_->foreign_imported_any) { (void) lookup_type(); return try_foreign(); @@ -2184,7 +2192,7 @@ static bool nb_type_put_unique_finalize(PyObject *o, "ownership status has become corrupted.", type_name(cpp_type), cpp_delete); -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) if (!nb_type_check((PyObject *)Py_TYPE(o))) { if (!is_new) { // Object already exists on the Python side. Maybe someone @@ -2323,11 +2331,11 @@ void nb_type_restore_ownership(PyObject *o, bool cpp_delete) noexcept { bool nb_type_isinstance(PyObject *o, const std::type_info *t, bool foreign_ok) noexcept { bool has_foreign = false; - type_data *d = nb_type_c2p(internals, t, &has_foreign); + type_data *d = nb_type_c2p(internals, t, foreign_ok ? &has_foreign : nullptr); if (d && PyType_IsSubtype(Py_TYPE(o), d->type_py)) return true; -#if !defined(NB_DISABLE_FOREIGN) - if (has_foreign && foreign_ok) +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign) return nullptr != nb_type_try_foreign( internals, t, +[](void *closure, pymb_binding *binding) -> void* { @@ -2344,11 +2352,11 @@ bool nb_type_isinstance(PyObject *o, const std::type_info *t, PyObject *nb_type_lookup(const std::type_info *t, bool foreign_ok) noexcept { bool has_foreign = false; - type_data *d = nb_type_c2p(internals, t, &has_foreign); + type_data *d = nb_type_c2p(internals, t, foreign_ok ? &has_foreign : nullptr); if (d) return (PyObject *) d->type_py; -#if !defined(NB_DISABLE_FOREIGN) - if (has_foreign && foreign_ok) +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign) return (PyObject *) nb_type_try_foreign( internals, t, +[](void *, pymb_binding *binding) -> void* { @@ -2580,38 +2588,38 @@ bool nb_inst_python_derived(PyObject *o) noexcept { (uint32_t) type_flags::is_python_type; } -void nb_type_set_foreign_defaults(bool export_all, bool import_all) { -#if !defined(NB_DISABLE_FOREIGN) +void nb_type_set_interop_defaults(bool export_all, bool import_all) { +#if !defined(NB_DISABLE_INTEROP) if (import_all && !internals->foreign_import_all) nb_type_enable_import_all(); if (export_all && !internals->foreign_export_all) nb_type_enable_export_all(); #else if (export_all || import_all) - raise("This libnanobind was built without foreign type support"); + raise("This libnanobind was built without interoperability support"); #endif } void nb_type_import(PyObject *pytype, const std::type_info *cpptype) { -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) lock_internals guard{internals}; check(PyType_Check(pytype), "not a type object"); nb_type_import_impl(pytype, cpptype); #else (void) pytype; (void) cpptype; - raise("This libnanobind was built without foreign type support"); + raise("This libnanobind was built without interoperability support"); #endif } void nb_type_export(PyObject *pytype) { -#if !defined(NB_DISABLE_FOREIGN) +#if !defined(NB_DISABLE_INTEROP) lock_internals guard{internals}; check(nb_type_check(pytype), "not a nanobind type"); nb_type_export_impl(nb_type_data((PyTypeObject *) pytype)); #else (void) pytype; - raise("This libnanobind was built without foreign type support"); + raise("This libnanobind was built without interoperability support"); #endif } From 4fb9c85326a93f485b17197830685512a38adb03 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Tue, 9 Sep 2025 22:14:04 -0600 Subject: [PATCH 6/8] Add unit tests --- docs/api_core.rst | 9 +- include/nanobind/nb_class.h | 11 +- src/nb_enum.cpp | 7 +- src/nb_foreign.cpp | 289 ++++++------ src/nb_func.cpp | 22 +- src/nb_internals.cpp | 22 +- src/nb_internals.h | 33 +- src/nb_type.cpp | 140 +++--- src/pymetabind.h | 670 ++++++++++++++++++++------- tests/CMakeLists.txt | 2 + tests/delattr_and_ensure_destroyed.h | 31 ++ tests/inter_module.cpp | 28 ++ tests/inter_module.h | 12 + tests/test_inter_module.py | 387 +++++++++++++++- tests/test_inter_module_1.cpp | 8 + tests/test_inter_module_2.cpp | 45 +- tests/test_inter_module_foreign.cpp | 229 +++++++++ 17 files changed, 1552 insertions(+), 393 deletions(-) create mode 100644 tests/delattr_and_ensure_destroyed.h create mode 100644 tests/test_inter_module_foreign.cpp diff --git a/docs/api_core.rst b/docs/api_core.rst index 44b91919a..dfa61f327 100644 --- a/docs/api_core.rst +++ b/docs/api_core.rst @@ -3239,10 +3239,11 @@ additional information and caveats about this feature. .. cpp:function:: void export_for_interop(handle type) - Make the Python type object *type*, which was bound in this nanobind domain, - be available for import by other binding libraries and other nanobind - domains. If they do so, then their bound functions will be able to accept - and return instances of *type*. + Make the Python type object *type*, which was created by a + :cpp:class:`nb::class_` or :cpp:class:`nb::enum_` binding statement in this + nanobind domain be available for import by other binding libraries and other + nanobind domains. If they do so, then their bound functions will be able to + accept and return instances of *type*. Repeatedly exporting the same type is idempotent. diff --git a/include/nanobind/nb_class.h b/include/nanobind/nb_class.h index 19913e3bb..347f5c674 100644 --- a/include/nanobind/nb_class.h +++ b/include/nanobind/nb_class.h @@ -204,7 +204,10 @@ enum class enum_flags : uint32_t { is_signed = (1 << 2), /// Is the underlying enumeration type Flag? - is_flag = (1 << 3) + is_flag = (1 << 3), + + /// Was the enum successfully registered with nanobind? + is_registered = (1 << 4), }; struct enum_init_data { @@ -340,6 +343,12 @@ inline void interoperate_by_default(bool export_all = true, } template inline void import_for_interop(handle type) { + if constexpr (!std::is_void_v) { + static_assert( + detail::is_base_caster_v>, + "Types that are intercepted by a type caster cannot use the " + "interoperability feature"); + } detail::nb_type_import(type.ptr(), std::is_void_v ? nullptr : &typeid(T)); } diff --git a/src/nb_enum.cpp b/src/nb_enum.cpp index 57964969f..11ab12263 100644 --- a/src/nb_enum.cpp +++ b/src/nb_enum.cpp @@ -64,6 +64,7 @@ PyObject *enum_create(enum_init_data *ed) noexcept { t->type = ed->type; t->type_py = (PyTypeObject *) result.ptr(); t->flags = ed->flags; + t->size = ed->size; t->enum_tbl.fwd = new enum_map(); t->enum_tbl.rev = new enum_map(); t->scope = ed->scope; @@ -72,7 +73,8 @@ PyObject *enum_create(enum_init_data *ed) noexcept { type_init_data *t = (type_init_data *) p; delete (enum_map *) t->enum_tbl.fwd; delete (enum_map *) t->enum_tbl.rev; - nb_type_unregister(t); + if (t->flags & (uint32_t) enum_flags::is_registered) + nb_type_unregister(t); free((char*) t->name); delete t; }); @@ -86,6 +88,7 @@ PyObject *enum_create(enum_init_data *ed) noexcept { return tp; } + t->flags |= (uint32_t) enum_flags::is_registered; result.attr("__nb_enum__") = tie_lifetimes; make_immortal(result.ptr()); @@ -93,7 +96,7 @@ PyObject *enum_create(enum_init_data *ed) noexcept { return result.release().ptr(); } -static type_init_data *enum_get_type_data(handle tp) { +type_init_data *enum_get_type_data(handle tp) { return (type_init_data *) (borrow(handle(tp).attr("__nb_enum__"))).data(); } diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp index fad76d2fd..604f93fd0 100644 --- a/src/nb_foreign.cpp +++ b/src/nb_foreign.cpp @@ -161,6 +161,11 @@ static int nb_foreign_translate_exception(void *eptr) noexcept { return 0; } +static void nb_foreign_free_local_binding(pymb_binding *binding) noexcept { + free((char *) binding->source_name); + PyMem_Free(binding); +} + static void nb_foreign_add_foreign_binding(pymb_binding *binding) noexcept { nb_internals *internals_ = internals; lock_internals guard{internals_}; @@ -169,10 +174,11 @@ static void nb_foreign_add_foreign_binding(pymb_binding *binding) noexcept { (const std::type_info *) binding->native_type); } -static void nb_foreign_remove_foreign_binding(pymb_binding *binding) noexcept { - nb_internals *internals_ = internals; - lock_internals guard{internals_}; - +// Common logic for removing imported & exported bindings from our type map. +// Caller must hold the internals lock. +static void nb_foreign_remove_binding_from_type(nb_internals *internals_, + const std::type_info *type, + pymb_binding *binding) noexcept { auto remove_from_list = [binding](void *list_head, nb_foreign_seq **to_free) -> void* { if (!nb_is_seq(list_head)) @@ -193,39 +199,52 @@ static void nb_foreign_remove_foreign_binding(pymb_binding *binding) noexcept { return list_head; }; - auto remove_from_type = [=](const std::type_info *type) { - nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; - auto it = type_c2p_slow.find(type); - check(it != type_c2p_slow.end(), - "foreign binding not registered upon removal"); - void *new_value = it->second; - nb_foreign_seq *to_free = nullptr; - if (nb_is_foreign(it->second)) { - new_value = remove_from_list(nb_get_foreign(it->second), &to_free); - if (new_value) - it.value() = new_value = nb_mark_foreign(new_value); - else - type_c2p_slow.erase(it); - } else { - auto *t = (type_data *) it->second; - nb_store_release(t->foreign_bindings, - remove_from_list( - nb_load_acquire(t->foreign_bindings), - &to_free)); - } - nb_type_update_c2p_fast(type, new_value); - PyMem_Free(to_free); - }; + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; + auto it = type_c2p_slow.find(type); + check(it != type_c2p_slow.end(), + "foreign binding not registered upon removal"); + void *new_value = it->second; + nb_foreign_seq *to_free = nullptr; + if (nb_is_foreign(it->second)) { + new_value = remove_from_list(nb_get_foreign(it->second), &to_free); + if (new_value) + it.value() = new_value = nb_mark_foreign(new_value); + else + type_c2p_slow.erase(it); + } else { + auto *t = (type_data *) it->second; + nb_store_release(t->foreign_bindings, + remove_from_list( + nb_load_acquire(t->foreign_bindings), + &to_free)); + } + nb_type_update_c2p_fast(type, new_value); + PyMem_Free(to_free); +} +static void nb_foreign_remove_local_binding(pymb_binding *binding) noexcept { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + nb_foreign_remove_binding_from_type( + internals_, (const std::type_info *) binding->native_type, + binding); +} + +static void nb_foreign_remove_foreign_binding(pymb_binding *binding) noexcept { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; bool should_remove_auto = should_autoimport_foreign(internals_, binding); if (auto it = internals_->foreign_manual_imports.find(binding); it != internals_->foreign_manual_imports.end()) { - remove_from_type((const std::type_info *) it->second); + nb_foreign_remove_binding_from_type( + internals_, (const std::type_info *) it->second, binding); should_remove_auto &= (it->second != binding->native_type); internals_->foreign_manual_imports.erase(it); } if (should_remove_auto) - remove_from_type((const std::type_info *) binding->native_type); + nb_foreign_remove_binding_from_type( + internals_, (const std::type_info *) binding->native_type, + binding); } static void nb_foreign_add_foreign_framework(pymb_framework *framework) @@ -247,19 +266,48 @@ static void nb_foreign_add_foreign_framework(pymb_framework *framework) internals->print_leak_warnings = false; } +static void nb_foreign_remove_foreign_framework(pymb_framework *framework) + noexcept { + if (is_alive() && framework->translate_exception && + framework->abi_lang == pymb_abi_lang_cpp) { + // Remove the exception translator we added for this framework. + // The interpreter is already finalizing, so we don't need to worry + // about locking. + nb_maybe_atomic *pcurr = &internals->translators; + while (auto *seq = pcurr->load_relaxed()) { + if (seq->translator == internals->foreign_exception_translator && + seq->payload == framework) { + pcurr->store_release(seq->next.load_relaxed()); + delete seq; + continue; + } + pcurr = &seq->next; + } + } +} + // (end of callbacks) // Advertise our existence, and the above callbacks, to other frameworks static void register_with_pymetabind(nb_internals *internals_) { // caller must hold the internals lock - if (internals_->foreign_registry) + if (internals_->foreign_self) return; - internals_->foreign_registry = pymb_get_registry(); - if (!internals_->foreign_registry) + pymb_registry *registry = pymb_get_registry(); + if (!registry) raise_python_error(); + const char *name_const = "nanobind " NB_ABI_TAG; + char *name_buf = (char *) alloca(strlen(name_const) + 1 + + (internals_->domain ? + 8 + strlen(internals_->domain) : 0)); + strcpy(name_buf, name_const); + if (internals_->domain) { + strcat(name_buf, " domain "); + strcat(name_buf, internals_->domain); + } auto *fw = new pymb_framework{}; - fw->name = "nanobind " NB_ABI_TAG; + fw->name = strdup_check(name_buf); #if defined(NB_FREE_THREADED) fw->flags = pymb_framework_bindings_usable_forever; #else @@ -271,12 +319,14 @@ static void register_with_pymetabind(nb_internals *internals_) { fw->to_python = nb_foreign_to_python; fw->keep_alive = nb_foreign_keep_alive; fw->translate_exception = nb_foreign_translate_exception; + fw->remove_local_binding = nb_foreign_remove_local_binding; + fw->free_local_binding = nb_foreign_free_local_binding; fw->add_foreign_binding = nb_foreign_add_foreign_binding; fw->remove_foreign_binding = nb_foreign_remove_foreign_binding; fw->add_foreign_framework = nb_foreign_add_foreign_framework; + fw->remove_foreign_framework = nb_foreign_remove_foreign_framework; internals_->foreign_self = fw; - auto *registry = internals_->foreign_registry; // pymb_add_framework() will call our add_foreign_framework and // add_foreign_binding method for each existing other framework/binding; // those need to lock internals, so unlock here @@ -331,7 +381,7 @@ static void nb_type_import_binding(pymb_binding *binding, // the C++ type by looking at the binding, and require that its ABI match ours. // Throws an exception on failure. Caller must hold the internals lock. void nb_type_import_impl(PyObject *pytype, const std::type_info *cpptype) { - if (!internals->foreign_registry) + if (!internals->foreign_self) register_with_pymetabind(internals); pymb_framework* foreign_self = internals->foreign_self; pymb_binding* binding = pymb_get_binding(pytype); @@ -378,7 +428,7 @@ void nb_type_enable_import_all() { if (internals_->foreign_import_all) return; internals_->foreign_import_all = true; - if (!internals_->foreign_registry) { + if (!internals_->foreign_self) { // pymb_add_framework tells us about every existing type when we // register, so if we register with import enabled, we're done register_with_pymetabind(internals_); @@ -390,22 +440,19 @@ void nb_type_enable_import_all() { // we can reuse the pymb callback functions. foreign_registry and // foreign_self never change once they're non-null, so we can accesss them // without locking here. - pymb_lock_registry(internals_->foreign_registry); - PYMB_LIST_FOREACH(struct pymb_binding*, binding, - internals_->foreign_registry->bindings) { - if (binding->framework != internals_->foreign_self && - pymb_try_ref_binding(binding)) { + struct pymb_registry *registry = internals_->foreign_self->registry; + pymb_lock_registry(registry); + PYMB_LIST_FOREACH(struct pymb_binding*, binding, registry->bindings) { + if (binding->framework != internals_->foreign_self) nb_foreign_add_foreign_binding(binding); - pymb_unref_binding(binding); - } } - pymb_unlock_registry(internals_->foreign_registry); + pymb_unlock_registry(registry); } // Expose hooks for other frameworks to use to work with the given nanobind // type object. Caller must hold the internals lock. void nb_type_export_impl(type_data *td) { - if (!internals->foreign_registry) + if (!internals->foreign_self) register_with_pymetabind(internals); void *foreign_bindings = nb_load_acquire(td->foreign_bindings); @@ -436,7 +483,7 @@ void nb_type_export_impl(type_data *td) { foreign_bindings = binding; } nb_store_release(td->foreign_bindings, foreign_bindings); - pymb_add_binding(internals->foreign_registry, binding); + pymb_add_binding(binding, /* tp_finalize_will_remove */ 1); // No need to call nb_type_update_c2p_fast: the map value (`td`) hasn't // changed, and a potential concurrent lookup that picked up the old value // of `td->foreign_bindings` is safe. @@ -450,7 +497,7 @@ void nb_type_enable_export_all() { if (internals_->foreign_export_all) return; internals_->foreign_export_all = true; - if (!internals_->foreign_registry) + if (!internals_->foreign_self) register_with_pymetabind(internals_); for (const auto& [type, value] : internals_->type_c2p_slow) { if (nb_is_foreign(value)) @@ -462,6 +509,7 @@ void nb_type_enable_export_all() { // Invoke `attempt(closure, binding)` for each foreign binding `binding` // that claims `type` and was not supplied by us, until one of them returns // non-null. Return that first non-null value, or null if all attempts failed. +// Failed attempts might be repeated if types are removed concurrently. // Requires that a previous call to nb_type_c2p() have been made for `type`. void *nb_type_try_foreign(nb_internals *internals_, const std::type_info *type, @@ -473,109 +521,68 @@ void *nb_type_try_foreign(nb_internals *internals_, #if defined(NB_FREE_THREADED) auto per_thread_guard = nb_type_lock_c2p_fast(internals_); nb_type_map_fast &type_c2p_fast = *per_thread_guard; + uint32_t updates_count = per_thread_guard.updates_count(); #else nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; #endif - - // We assume nb_type_c2p already ran for this type, so that there's - // no need to handle a cache miss here. - void *foreign_bindings = nullptr; - if (void *result = type_c2p_fast.lookup(type); nb_is_foreign(result)) - foreign_bindings = nb_get_foreign(result); - else if (auto *t = (type_data *) result) - foreign_bindings = nb_load_acquire(t->foreign_bindings); - if (!foreign_bindings) - return nullptr; - - if (NB_LIKELY(!nb_is_seq(foreign_bindings))) { - // Single foreign binding - check that it's not our own - auto *binding = (pymb_binding *) foreign_bindings; - if (binding->framework != internals_->foreign_self && - pymb_try_ref_binding(binding)) { + do { + // We assume nb_type_c2p already ran for this type, so that there's + // no need to handle a cache miss here. + void *foreign_bindings = nullptr; + if (void *result = type_c2p_fast.lookup(type); nb_is_foreign(result)) + foreign_bindings = nb_get_foreign(result); + else if (auto *t = (type_data *) result) + foreign_bindings = nb_load_acquire(t->foreign_bindings); + if (!foreign_bindings) + return nullptr; + + if (NB_LIKELY(!nb_is_seq(foreign_bindings))) { + // Single foreign binding - check that it's not our own + auto *binding = (pymb_binding *) foreign_bindings; + if (binding->framework != internals_->foreign_self) { #if defined(NB_FREE_THREADED) - // attempt() might execute Python code; drop the map mutex - // to avoid a deadlock - per_thread_guard = {}; + // attempt() might execute Python code; drop the map mutex + // to avoid a deadlock + per_thread_guard = {}; #endif - void *result = attempt(closure, binding); - pymb_unref_binding(binding); - return result; + return attempt(closure, binding); + } + return nullptr; } - return nullptr; - } - - // Multiple foreign bindings - try all except our own. -#if !defined(NB_FREE_THREADED) - nb_foreign_seq *current = nb_get_seq(foreign_bindings); - while (current) { - auto *binding = current->value; - if (binding->framework != internals_->foreign_self && - pymb_try_ref_binding(binding)) { - void *result = attempt(closure, binding); - pymb_unref_binding(binding); - if (result) - return result; - } - current = current->next; - } - return nullptr; -#else - // In free-threaded mode, this is tricky: we need to drop the - // per_thread_guard before calling attempt(), but once we do so, - // any of these bindings that might be in the middle of getting deleted - // can be concurrently removed from the linked list, which would interfere - // with our iteration. Copy the binding pointers out of the list to avoid - // this problem. - - // Count the number of foreign bindings we might see - size_t len = 0; - nb_foreign_seq *current = nb_get_seq(foreign_bindings); - while (current) { - ++len; - current = nb_load_acquire(current->next); - } - // Allocate temporary storage for that many pointers - pymb_binding **scratch = - (pymb_binding **) alloca(len * sizeof(pymb_binding*)); - pymb_binding **scratch_tail = scratch; - - // Iterate again, taking out strong references and saving pointers to - // our scratch storage. Concurrency notes: - // - If bindings are removed while we iterate, we may either visit them - // (and do nothing since try_ref returns false) or skip them. Binding - // removal will lock all c2p_fast maps in between when it modifies the - // linked list and when it deallocates the removed node, so we're safe - // from concurrent deallocation as long as we hold the lock. - // - If bindings are added at the front of the list while we iterate, - // they don't impact us since we're working with a local copy of the - // head ptr `foreign_bindings`. - // - If bindings are added at the rear of the list while we iterate, - // we may either include them (if we didn't use some of the scratch - // slots we allocated previously) or not, but we'll always decref - // everything we incref. - current = nb_get_seq(foreign_bindings); - while (current && scratch != scratch_tail + len) { - auto *binding = current->value; - if (binding->framework != internals_->foreign_self && - pymb_try_ref_binding(binding)) - *scratch_tail++ = binding; - current = nb_load_acquire(current->next); - } + // Multiple foreign bindings - try all except our own. + nb_foreign_seq *current = nb_get_seq(foreign_bindings); + while (current) { + auto *binding = current->value; + if (binding->framework != internals_->foreign_self) { +#if defined(NB_FREE_THREADED) + per_thread_guard = {}; +#endif + void *result = attempt(closure, binding); + if (result) + return result; - // Drop the lock and proceed using only our saved binding pointers. - // Since we obtained strong references to them, there is no remaining - // concurrent-destruction hazard. - per_thread_guard = {}; - void *result = nullptr; - while (scratch != scratch_tail) { - if (!result) - result = attempt(closure, *scratch); - pymb_unref_binding(*scratch); - ++scratch; - } - return result; +#if defined(NB_FREE_THREADED) + // Re-acquire lock to continue iteration. If we missed an + // update while the lock was released, start our lookup over + // in case the update removed the node we're on. + per_thread_guard = nb_type_lock_c2p_fast(internals_); + if (per_thread_guard.updates_count() != updates_count) { + // Concurrent update occurred; retry + updates_count = per_thread_guard.updates_count(); + break; + } #endif + } + current = current->next; + } + if (current) { + // We broke out early due to a concurrent update. Retry + // from the top. + continue; + } + return nullptr; + } while (true); } NAMESPACE_END(detail) diff --git a/src/nb_func.cpp b/src/nb_func.cpp index 2dad4f074..b95164d8d 100644 --- a/src/nb_func.cpp +++ b/src/nb_func.cpp @@ -1307,25 +1307,21 @@ static uint32_t nb_func_render_signature(const func_data *f, bool found = false; auto it = internals_->type_c2p_slow.find(*descr_type); if (it != internals_->type_c2p_slow.end()) { - object ty; + handle th; #if !defined(NB_DISABLE_INTEROP) if (nb_is_foreign(it->second)) { void *bindings = nb_get_foreign(it->second); - pymb_binding *binding = - nb_is_seq(bindings) ? - nb_get_seq(bindings)->value : - (pymb_binding *) bindings; - if (pymb_try_ref_binding(binding)) { - ty = borrow(binding->pytype); - pymb_unref_binding(binding); - } + if (!nb_is_seq(bindings)) + th = ((pymb_binding *) bindings)->pytype; + else + th = nb_get_seq(bindings)->value->pytype; } else #endif - ty = borrow(((type_data *) it->second)->type_py); - if (ty) { - buf.put_dstr((borrow(ty.attr("__module__"))).c_str()); + th = ((type_data *) it->second)->type_py; + if (th) { + buf.put_dstr((borrow(th.attr("__module__"))).c_str()); buf.put('.'); - buf.put_dstr((borrow(ty.attr("__qualname__"))).c_str()); + buf.put_dstr((borrow(th.attr("__qualname__"))).c_str()); found = true; } } diff --git a/src/nb_internals.cpp b/src/nb_internals.cpp index 5544c3dab..63e3901cc 100644 --- a/src/nb_internals.cpp +++ b/src/nb_internals.cpp @@ -251,8 +251,20 @@ static void internals_cleanup() { if (!p->type_c2p_slow.empty()) { size_t type_leaks = 0; for (const auto &kv : p->type_c2p_slow) { - if (!nb_is_foreign(kv.second)) - ++type_leaks; +#if !defined(NB_DISABLE_INTEROP) + if (nb_is_foreign(kv.second)) { + if (nb_is_seq(kv.second)) { + auto *seq = nb_get_seq(kv.second); + while (seq) { + auto *next = seq->next; + PyMem_Free(seq); + seq = next; + } + } + continue; + } +#endif + ++type_leaks; } if (type_leaks && print_leak_warnings) { @@ -299,10 +311,13 @@ static void internals_cleanup() { t = next; } +#if !defined(NB_DISABLE_INTEROP) if (p->foreign_self) { - pymb_list_unlink(&p->foreign_self->link); + pymb_remove_framework(p->foreign_self); + free((void *) p->foreign_self->name); delete p->foreign_self; } +#endif for (auto &kv : p->types_in_c2p_fast) { if (!kv.second) @@ -382,6 +397,7 @@ NB_NOINLINE void init(const char *name) { p->shard_mask = shard_count - 1; #endif p->shard_count = shard_count; + p->domain = name; str nb_name("nanobind"); p->nb_module = PyModule_NewObject(nb_name.ptr()); diff --git a/src/nb_internals.h b/src/nb_internals.h index a06d0cc54..ec503768e 100644 --- a/src/nb_internals.h +++ b/src/nb_internals.h @@ -377,6 +377,8 @@ struct nb_type_map_per_thread { explicit nb_type_map_per_thread(nb_internals &internals_); ~nb_type_map_per_thread(); + struct guard; + struct guard { guard() = default; guard(guard&& other) noexcept : parent(other.parent) { @@ -394,6 +396,9 @@ struct nb_type_map_per_thread { nb_type_map_fast& operator*() const { return parent->map; } nb_type_map_fast* operator->() const { return &parent->map; } + uint32_t updates_count() const { return parent->updates; } + void note_updated() { ++parent->updates; } + private: friend nb_type_map_per_thread; explicit guard(nb_type_map_per_thread &parent_) : parent(&parent_) { @@ -403,10 +408,14 @@ struct nb_type_map_per_thread { }; guard lock() { return guard{*this}; } - // Mutex protecting accesses to `map` + // Mutex protecting accesses to `updates` and `map` PyMutex mutex{}; - nb_type_map_fast map; + + // The number of times `map` has been modified + uint32_t updates = 0; + nb_internals &internals; + nb_type_map_fast map; // In order to access or modify `next`, you must hold the nb_internals mutex // (this->mutex is not needed for iteration) @@ -574,10 +583,6 @@ struct nb_translator_seq { * - `types_in_c2p_fast`: Used only when accessing or updating `type_c2p_slow`, so * protecting it with the global `mutex` adds no additional overhead. * - * - `foreign_registry`, `foreign_self`: created only once on demand, - * protected by `mutex`; often OK to read without locking since they never - * change once set - * * - `type_c2p_fast`: this data structure is *hot* and mostly read. It serves * as a cache of `type_c2p_slow`, mapping `std::type_info` to type data using * pointer-based comparisons. On free-threaded builds, each thread gets its @@ -595,6 +600,13 @@ struct nb_translator_seq { * - `funcs`: data structure for function leak tracking. Not used in * free-threaded mode. * + * - `foreign_self`, `foreign_exception_translator`: created only once on + * demand, protected by `mutex`; often OK to read without locking since + * they never change once set + * + * - `foreign_manual_imports`: accessed and modified only during binding import + * and removal, which are rare; protected by internals lock + * * - `print_leak_warnings`, `print_implicit_cast_warnings`, * `foreign_export`, `foreign_import`: simple configuration flags. * No protection against concurrent conflicting updates. @@ -694,9 +706,6 @@ struct nb_internals { /// we can skip some logic in nb_type_get/put. bool foreign_imported_any = false; - /// Pointer to pymetabind registry, if enabled - pymb_registry *foreign_registry = nullptr; - /// Pointer to our own framework object in pymetabind, if enabled pymb_framework *foreign_self = nullptr; @@ -732,6 +741,9 @@ struct nb_internals { // Size of the 'shards' data structure. Only rarely accessed, hence at the end size_t shard_count = 1; + + // NB_DOMAIN string used to initialize this nanobind domain + const char *domain; }; /// Convenience macro to potentially access cached functions @@ -810,6 +822,9 @@ NB_INLINE type_data *nb_type_data(PyTypeObject *o) noexcept{ #endif } +// Fetch the type record from an enum created by nanobind +extern type_init_data *enum_get_type_data(handle tp); + inline void *inst_ptr(nb_inst *self) { void *ptr = (void *) ((intptr_t) self + self->offset); return self->direct ? ptr : *(void **) ptr; diff --git a/src/nb_type.cpp b/src/nb_type.cpp index cb4586840..d0412a3bc 100644 --- a/src/nb_type.cpp +++ b/src/nb_type.cpp @@ -468,22 +468,27 @@ void nb_type_update_c2p_fast(const std::type_info *type, void *value) noexcept { nb_internals *internals_ = internals; auto it_alias = internals_->types_in_c2p_fast.find(type); if (it_alias != internals_->types_in_c2p_fast.end()) { - bool found = false; #if defined(NB_FREE_THREADED) for (nb_type_map_per_thread *cache = internals_->type_c2p_per_thread_head; cache; cache = cache->next) { - found |= nb_type_update_cache(*cache->lock(), it_alias->first, - (nb_alias_seq *) it_alias->second, - value); + auto guard = cache->lock(); + bool found = nb_type_update_cache(*guard, it_alias->first, + (nb_alias_seq *) it_alias->second, + value); + if (found) + guard.note_updated(); } + // We can't require that we found a match, because the type might + // have been cached only by a thread that has since exited. #else - found = nb_type_update_cache(internals_->type_c2p_fast, it_alias->first, - (nb_alias_seq *) it_alias->second, value); -#endif + bool found = nb_type_update_cache( + internals_->type_c2p_fast, it_alias->first, + (nb_alias_seq *) it_alias->second, value); check(found, "nanobind::detail::nb_type_update_c2p_fast(\"%s\"): " "types_in_c2p_fast and type_c2p_fast are inconsistent", type_name(type)); +#endif } } @@ -516,61 +521,51 @@ bool nb_type_register(type_data *t, type_data **conflict) noexcept { void nb_type_unregister(type_data *t) noexcept { nb_internals *internals_ = internals; - nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; - - lock_internals guard(internals_); - auto it_slow = type_c2p_slow.find(t->type); - check(it_slow != type_c2p_slow.end() && it_slow->second == t, - "nanobind::detail::nb_type_unregister(\"%s\"): could not " - "find type!", t->name); -#if defined(NB_DISABLE_INTEROP) - type_c2p_slow.erase(it_slow); - nb_type_update_c2p_fast(t->type, nullptr); -#else +#if !defined(NB_DISABLE_INTEROP) void *foreign_bindings = nb_load_acquire(t->foreign_bindings); - pymb_binding *binding_to_free = nullptr; - nb_foreign_seq *node_to_free = nullptr; + pymb_binding *binding_to_remove = nullptr; if (nb_is_seq(foreign_bindings)) { nb_foreign_seq *node = nb_get_seq(foreign_bindings); - if (node->value->framework == internals_->foreign_self) { - binding_to_free = node->value; - node_to_free = node; - foreign_bindings = nb_mark_seq(node->next); - } + if (node->value->framework == internals_->foreign_self) + binding_to_remove = node->value; } else if (auto *binding = (pymb_binding *) foreign_bindings; binding && binding->framework == internals_->foreign_self) { - binding_to_free = binding; - foreign_bindings = nullptr; + binding_to_remove = binding; } + if (binding_to_remove) + pymb_remove_binding(binding_to_remove); +#endif + + lock_internals guard(internals_); + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; + auto it_slow = type_c2p_slow.find(t->type); + check(it_slow != type_c2p_slow.end() && it_slow->second == t, + "nanobind::detail::nb_type_unregister(\"%s\"): could not " + "find type!", t->name); - void *new_value; +#if !defined(NB_DISABLE_INTEROP) + foreign_bindings = nb_load_acquire(t->foreign_bindings); if (foreign_bindings) { - new_value = nb_mark_foreign(foreign_bindings); + void *new_value = nb_mark_foreign(foreign_bindings); it_slow.value() = new_value; - } else { - new_value = nullptr; - type_c2p_slow.erase(it_slow); - } - nb_type_update_c2p_fast(t->type, new_value); - - // Don't actually free the binding until we've updated the c2p fast map. - // Other threads may concurrently be in nb_type_try_foreign(); see - // comments there for more details on the synchronization here. - if (binding_to_free) { - pymb_remove_binding(internals_->foreign_registry, binding_to_free); - free((char *) binding_to_free->source_name); - PyMem_Free(binding_to_free); - PyMem_Free(node_to_free); + nb_type_update_c2p_fast(t->type, new_value); + return; } #endif + type_c2p_slow.erase(it_slow); + nb_type_update_c2p_fast(t->type, nullptr); } -static void nb_type_dealloc(PyObject *o) { +static void nb_type_finalize(PyObject *o) { type_data *t = nb_type_data((PyTypeObject *) o); if (t->type && (t->flags & (uint32_t) type_flags::is_python_type) == 0) nb_type_unregister(t); +} + +static void nb_type_dealloc(PyObject *o) { + type_data *t = nb_type_data((PyTypeObject *) o); if (t->flags & (uint32_t) type_flags::has_implicit_conversions) { PyMem_Free(t->implicit.cpp); @@ -900,7 +895,7 @@ static PyObject *nb_type_from_metaclass(PyTypeObject *meta, PyObject *mod, if (slot == 0) { break; - } else if (slot * sizeof(nb_slot) < (int) sizeof(type_slots)) { + } else if (slot * sizeof(nb_slot) <= (int) sizeof(type_slots)) { *(((void **) ht) + type_slots[slot - 1].direct) = ts->pfunc; } else { PyErr_Format(PyExc_RuntimeError, @@ -996,6 +991,7 @@ static PyTypeObject *nb_type_tp(size_t supplement) noexcept { PyType_Slot slots[] = { { Py_tp_base, &PyType_Type }, + { Py_tp_finalize, (void *) nb_type_finalize }, { Py_tp_dealloc, (void *) nb_type_dealloc }, { Py_tp_setattro, (void *) nb_type_setattro }, { Py_tp_init, (void *) nb_type_init }, @@ -1538,23 +1534,39 @@ PyObject *call_one_arg(PyObject *fn, PyObject *arg) noexcept { static NB_NOINLINE bool nb_type_get_implicit(PyObject *src, const std::type_info *cpp_type_src, const type_data *dst_type, + nb_internals *internals_, cleanup_list *cleanup, void **out) noexcept { - if (dst_type->implicit.cpp && cpp_type_src) { + if (dst_type->implicit.cpp && + (cpp_type_src || internals_->foreign_imported_any)) { const std::type_info **it = dst_type->implicit.cpp; const std::type_info *v; - while ((v = *it++)) { - if (v == cpp_type_src || *v == *cpp_type_src) - goto found; + if (cpp_type_src) { + while ((v = *it++)) { + if (v == cpp_type_src || *v == *cpp_type_src) + goto found; + } + it = dst_type->implicit.cpp; } - it = dst_type->implicit.cpp; while ((v = *it++)) { - PyTypeObject *pytype = - (PyTypeObject *) nb_type_lookup(v, /* foreign_ok */ true); - if (pytype && PyType_IsSubtype(Py_TYPE(src), pytype)) + bool has_foreign = false; + type_data *td = nb_type_c2p(internals_, v, &has_foreign); + if (td && PyType_IsSubtype(Py_TYPE(src), td->type_py)) + goto found; +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign && nb_type_try_foreign( + internals_, v, + +[](void *src_, pymb_binding *binding) -> void* { + PyObject *src = (PyObject *) src_; + if (PyType_IsSubtype(Py_TYPE(src), binding->pytype)) + return binding; + return nullptr; + }, + src)) goto found; +#endif } } @@ -1713,7 +1725,7 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, if (dst_type && (dst_type->flags & (uint32_t) type_flags::has_implicit_conversions) && - nb_type_get_implicit(src, cpp_type_src, dst_type, cleanup, out)) + nb_type_get_implicit(src, cpp_type_src, dst_type, internals_, cleanup, out)) return true; } else if (!src_is_nb_type && internals_->foreign_imported_any) { // If we never determined the dst type and it might be foreign, @@ -2615,8 +2627,24 @@ void nb_type_import(PyObject *pytype, const std::type_info *cpptype) { void nb_type_export(PyObject *pytype) { #if !defined(NB_DISABLE_INTEROP) lock_internals guard{internals}; - check(nb_type_check(pytype), "not a nanobind type"); - nb_type_export_impl(nb_type_data((PyTypeObject *) pytype)); + if (nb_type_check(pytype)) { + nb_type_export_impl(nb_type_data((PyTypeObject *) pytype)); + return; + } + if (hasattr(pytype, "__nb_enum__")) { + static_assert(offsetof(type_data, type) == 8 + sizeof(void*), + "Don't change the offset of type_data::type in future " + "ABI versions; it must be consistent for the following " + "logic to work"); + type_data *td = enum_get_type_data(pytype); + if (auto it = internals->type_c2p_slow.find(td->type); + it != internals->type_c2p_slow.end() && it->second == td) { + nb_type_export_impl(td); + return; + } + } + raise("Can't export %s: not a nanobind class or enum bound in this " + "domain", repr(pytype).c_str()); #else (void) pytype; raise("This libnanobind was built without interoperability support"); diff --git a/src/pymetabind.h b/src/pymetabind.h index e89390af0..a4a7cca43 100644 --- a/src/pymetabind.h +++ b/src/pymetabind.h @@ -6,13 +6,26 @@ * This functionality is intended to be used by the framework itself, * rather than by users of the framework. * - * This is version 0.1+dev of pymetabind. Changelog: + * This is version 0.2+dev of pymetabind. Changelog: * - * Unreleased: Use a bitmask for `pymb_framework::flags` and add leak_safe - * flag. Change `translate_exception` to be non-throwing. + * Unreleased: Don't do a Py_DECREF in `pymb_remove_framework` since the + * interpreter might already be finalized at that point. + * Revamp binding lifetime logic. Add `remove_local_binding` + * and `free_local_binding` callbacks. + * Add `pymb_framework::registry` and use it to simplify + * the signatures of `pymb_remove_framework`, + * `pymb_add_binding`, and `pymb_remove_binding`. + * + * Version 0.2: Use a bitmask for `pymb_framework::flags` and add leak_safe + * 2025-09-11 flag. Change `translate_exception` to be non-throwing. * Add casts from PyTypeObject* to PyObject* where needed. * Fix typo in Py_GIL_DISABLED. Add noexcept to callback types. * Rename `hook` -> `link` in linked list nodes. + * Use `static inline` linkage in C. Free registry on exit. + * Clear list hooks when adding frameworks/bindings in case + * the user didn't zero-initialize. Avoid abi_extra string + * comparisons if the strings are already pointer-equal. + * Add `remove_foreign_framework` callback. * * Version 0.1: Initial draft. ABI may change without warning while we * 2025-08-16 prove out the concept. Please wait for a 1.0 release @@ -46,16 +59,25 @@ #include #include +#include #if !defined(PY_VERSION_HEX) # error You must include Python.h before this header #endif +// `inline` in C implies a promise to provide an out-of-line definition +// elsewhere; in C++ it does not. +#ifdef __cplusplus +# define PYMB_INLINE inline +#else +# define PYMB_INLINE static inline +#endif + /* * There are two ways to use this header file. The default is header-only style, - * where all functions are defined as `inline`. If you want to emit functions - * as non-inline, perhaps so you can link against them from non-C/C++ code, - * then do the following: + * where all functions are defined as `inline` (C++) / `static inline` (C). + * If you want to emit functions as non-inline, perhaps so you can link against + * them from non-C/C++ code, then do the following: * - In every compilation unit that includes this header, `#define PYMB_FUNC` * first. (The `PYMB_FUNC` macro will be expanded in place of the "inline" * keyword, so you can also use it to add any other declaration attributes @@ -65,7 +87,7 @@ * compilation unit that doesn't request `PYMB_DECLS_ONLY`. */ #if !defined(PYMB_FUNC) -#define PYMB_FUNC inline +#define PYMB_FUNC PYMB_INLINE #endif #if defined(__cplusplus) @@ -141,11 +163,11 @@ struct pymb_list { struct pymb_list_node head; }; -inline void pymb_list_init(struct pymb_list* list) { +PYMB_INLINE void pymb_list_init(struct pymb_list* list) { list->head.prev = list->head.next = &list->head; } -inline void pymb_list_unlink(struct pymb_list_node* node) { +PYMB_INLINE void pymb_list_unlink(struct pymb_list_node* node) { if (node->next) { node->next->prev = node->prev; node->prev->next = node->next; @@ -153,8 +175,8 @@ inline void pymb_list_unlink(struct pymb_list_node* node) { } } -inline void pymb_list_append(struct pymb_list* list, - struct pymb_list_node* node) { +PYMB_INLINE void pymb_list_append(struct pymb_list* list, + struct pymb_list_node* node) { pymb_list_unlink(node); struct pymb_list_node* tail = list->head.prev; tail->next = node; @@ -163,6 +185,10 @@ inline void pymb_list_append(struct pymb_list* list, node->next = &list->head; } +PYMB_INLINE int pymb_list_is_empty(struct pymb_list* list) { + return list->head.next == &list->head; +} + #define PYMB_LIST_FOREACH(type, name, list) \ for (type name = (type) (list).head.next; \ name != (type) &(list).head; \ @@ -194,8 +220,14 @@ struct pymb_registry { // Linked list of registered `pymb_binding` structures struct pymb_list bindings; + // Heap-allocated PyMethodDef for bound type weakref callback + PyMethodDef* weakref_callback_def; + // Reserved for future extensions; currently set to 0 - uint32_t reserved; + uint16_t reserved; + + // Set to true when the capsule that points to this registry is destroyed + uint8_t deallocate_when_empty; #if defined(Py_GIL_DISABLED) // Mutex guarding accesses to `frameworks` and `bindings`. @@ -205,15 +237,19 @@ struct pymb_registry { }; #if defined(Py_GIL_DISABLED) -inline void pymb_lock_registry(struct pymb_registry* registry) { +PYMB_INLINE void pymb_lock_registry(struct pymb_registry* registry) { PyMutex_Lock(®istry->mutex); } -inline void pymb_unlock_registry(struct pymb_registry* registry) { +PYMB_INLINE void pymb_unlock_registry(struct pymb_registry* registry) { PyMutex_Unlock(®istry->mutex); } #else -inline void pymb_lock_registry(struct pymb_registry*) {} -inline void pymb_unlock_registry(struct pymb_registry*) {} +PYMB_INLINE void pymb_lock_registry(struct pymb_registry* registry) { + (void) registry; +} +PYMB_INLINE void pymb_unlock_registry(struct pymb_registry* registry) { + (void) registry; +} #endif struct pymb_binding; @@ -253,7 +289,7 @@ enum pymb_framework_flags { * and unmodified (except as documented below) until the Python interpreter * is finalized. After finalization, such as in a `Py_AtExit` handler, if * all bindings have been removed already, you may optionally clean up by - * calling `pymb_list_unlink(&framework->link)` and then deallocating the + * calling `pymb_remove_framework()` and then deallocating the * `pymb_framework` structure. * * All fields of this structure are set before it is made visible to other @@ -267,6 +303,10 @@ struct pymb_framework { // added; protected by the `pymb_registry::mutex` in free-threaded builds. struct pymb_list_node link; + // Link to the `pymb_registry` that this framework is registered with. + // Filled in by `pymb_add_framework()`. + struct pymb_registry* registry; + // Human-readable description of this framework, as a NUL-terminated string const char* name; @@ -390,6 +430,22 @@ struct pymb_framework { // No synchronization is required to call this method. int (*translate_exception)(void* eptr) PYMB_NOEXCEPT; + // Notify this framework that one of its own bindings is being removed. + // This will occur synchronously from within a call to + // `pymb_remove_binding()`. Don't free the binding yet; wait for a later + // call to `free_local_binding`. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*remove_local_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; + + // Request this framework to free one of its own binding structures. + // A call to `pymb_remove_binding()` will eventually result in a call to + // this method, once pymetabind can prove no one is concurrently using the + // binding anymore. + // + // No synchronization is required to call this method. + void (*free_local_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; + // Notify this framework that some other framework published a new binding. // This call will be made after the new binding has been linked into the // `pymb_registry::bindings` list. @@ -411,100 +467,175 @@ struct pymb_framework { // The `pymb_registry::mutex` or GIL will be held when calling this method. void (*add_foreign_framework)(struct pymb_framework* framework) PYMB_NOEXCEPT; - // There is no remove_foreign_framework(); the interpreter has - // already been finalized at that point, so there's nothing for the - // callback to do. + // Notify this framework that some other framework is being destroyed. + // This call will be made after the framework has been removed from the + // `pymb_registry::frameworks` list. + // + // This can only occur during interpreter finalization, so no + // synchronization is required. It might occur very late in interpreter + // finalization, such as from a Py_AtExit handler, so it shouldn't + // execute Python code. + void (*remove_foreign_framework)(struct pymb_framework* framework) PYMB_NOEXCEPT; }; /* * Information about one type binding that belongs to a registered framework. * + * ### Creating bindings + * * A framework that binds some type and wants to allow other frameworks to * work with objects of that type must create a `pymb_binding` structure for * the type. This can be allocated in any way that the framework prefers (e.g., - * on the heap or within the type object). Once filled out, the binding - * structure should be passed to `pymb_add_binding()`. If the Python type object - * underlying the binding is to be deallocated, a `pymb_remove_binding()` call - * must be made, and the `pymb_binding` structure cannot be deallocated until - * `pymb_remove_binding()` returns. The call to `pymb_remove_binding()` - * must occur *during* deallocation of the binding's Python type object, i.e., - * at a time when `Py_REFCNT(pytype) == 0` but the storage for `pytype` is not - * yet eligible to be reused for another object. Many frameworks use a custom - * metaclass, and can add the call to `pymb_remove_binding()` from the metaclass - * `tp_dealloc`; those that don't can use a weakref callback on the type object - * instead. The constraint on destruction timing allows `pymb_try_ref_binding()` - * to temporarily prevent the binding's destruction by incrementing the type - * object's reference count. + * on the heap or within the type object). Any fields without a meaningful + * value must be zero-filled. Once filled out, the binding structure should be + * passed to `pymb_add_binding()`. This will advertise the binding to other + * frameworks' `add_foreign_binding` hooks. It also creates a capsule object + * that points to the `pymb_binding` structure, and stores this capsule in the + * bound type's dict as the attribute "__pymetabind_binding__". + * The intended use of both of these is discussed later on in this comment. + * + * ### Removing bindings + * + * From a user perspective, a binding can be removed for either of two reasons: + * + * - its capsule was destroyed, such as by `del MyType.__pymetabind_binding__` + * - its type object is being finalized + * + * These both result in a call to `pymb_remove_binding()` that begins the + * removal process, but you should not call that function yourself, except + * from a metatype `tp_finalize` as described below. Some time after the call + * to `pymb_remove_binding()`, pymetabind will call the binding's framework's + * `free_local_binding` hook to indicate that it's safe to actually free the + * `pymb_binding` structure. + * + * By default, pymetabind detects the finalization of a binding's type object + * by creating a weakref to the type object with an appropriate callback. This + * works fine, but requires several objects to be allocated, so it is not ideal + * from a memory overhead perspective. If you control the bound type's metatype, + * you can reduce this overhead by modifying the metatype's `tp_finalize` slot + * to call `pymb_remove_binding()`. If you tell pymetabind that you have done + * so, using the `tp_finalize_will_remove` argument to `pymb_add_binding()`, + * then pymetabind won't need to create the weakref and its callback. + * + * ### Removing bindings: the gory details + * + * The implementation of the removal process is somewhat complex in order to + * protect other threads that might be concurrently using a binding in + * free-threaded builds. `pymb_remove_binding()` stops new uses of the binding + * from beginning, by notifying other frameworks' `remove_foreign_binding` + * hooks and changing the binding capsule so `pymb_get_binding()` won't work. + * Existing uses might be ongoing though, so we must wait for them to complete + * before freeing the binding structure. The technique we choose is to wait for + * the next (or current) garbage collection to finish. GC stops all threads + * before it scans the heap. An attached thread state (one that can call + * CPython API functions) can't be stopped without its consent, so GC will + * wait for it to detach. A thread state can only become detached explicitly + * (e.g. Py_BEGIN_ALLOW_THREADS) or in the bytecode interpreter. As long as + * foreign frameworks don't hold `pymb_binding` pointers across calls into + * the bytecode interpreter in places their `remove_foreign_binding` hook + * can't see, this technique avoids use-after-free without introducing any + * contention on a shared atomic in the binding object. + * + * One pleasant aspect of this scheme: due to their use of deferred reference + * counting, type objects in free-threaded Python can only be freed during a + * GC pass. There is even a stop-all-threads (to check for resurrected objects) + * in between when GC executes finalizers and when it actually destroys the + * garbage. This winds up letting us obtain the "wait for next GC pause before + * freeing the binding" behavior very cheaply when the binding is being removed + * due to the deletion of its type. + * + * On non-free-threaded Python builds, none of the above is a concern, and + * `pymb_remove_binding()` can synchronously free the `pymb_binding` structure. + * + * ### Keeping track of other frameworks' bindings + * + * In order to work with Python objects bound by another framework, yours + * must be able to locate a `pymb_binding` structure for that type. It is + * anticipated that most frameworks will maintain their own private + * type-to-binding maps, which they can keep up-to-date via their + * `add_foreign_binding` and `remove_foreign_binding` hooks. It is important + * to think carefully about how to design the synchronization for these maps + * so that lookups do not return pointers to bindings that may have been + * deallocated. The remainder of this section provides some suggestions. + * + * The recommended way to handle synchronization is to protect your type lookup + * map with a readers/writer lock. In your `remove_foreign_binding` hook, + * obtain a write lock, and hold it while removing the corresponding entry from + * the map. Before performing a type lookup, obtain a read lock. If the lookup + * succeeds, you can release the read lock and (due to the two-phase removal + * process described above) continue to safely use the binding for as long as + * your Python thread state remains attached. It is important not to hold the + * read lock while executing arbitrary Python code, since a deadlock would + * result if the binding were removed (requiring a write lock) while the read + * lock were held. Note that `pymb_framework::from_python` for many popular + * frameworks can execute arbitrary Python code to perform implicit conversions. + * + * If you're trying multiple bindings for an operation, one option is to copy + * all their pointers to temporary storage before releasing the read lock. + * (While concurrent updates may modify the data structure, the pymb_binding + * structures it points to will remain valid for long enough.) If you prefer + * to avoid the copy by unlocking for each attempt and then relocking to + * advance to the next binding, be sure to consider the possibility that your + * iterator might have been invalidated due to a concurrent update while you + * weren't holding the lock. + * + * The lock on a single shared type lookup map is a contention bottleneck, + * especially if you don't have a readers/writer lock and wish to get by with + * an ordinary mutex. To improve performance, you can give each thread its + * own lookup map, and require `remove_foreign_binding` to update all of them. + * As long as the per-thread maps are always visited in a consistent order + * when removing a binding, the splitting shouldn't introduce new deadlocks. + * Since each thread can have a separate mutex for its separate map, contention + * occurs only when bindings are being added or removed, which is much less + * common than using them. + * + * ### Using the binding capsule * * Each Python type object for which a `pymb_binding` exists will have an * attribute "__pymetabind_binding__" whose value is a capsule object * that contains the `pymb_binding` pointer under the name "pymetabind_binding". - * The attribute is set during `pymb_add_binding()`. This is provided to allow: + * The attribute is set during `pymb_add_binding()`, and is used by + * `pymb_get_binding()` to map a type object to a binding. The capsule allows: + * * - Determining which framework to call for a foreign `keep_alive` operation + * * - Locating `pymb_binding` objects for types written in a different language * than yours (where you can't look up by the `pymb_binding::native_type`), * so that you can work with their contents using non-Python-specific * cross-language support + * * - Extracting the native object from a Python object without being too picky * about what type it is (risky, but maybe you have out-of-band information * that shows it's safe) + * * The preferred mechanism for same-language object access is to maintain a * hashtable keyed on `pymb_binding::native_type` and look up the binding for * the type you want/have. Compared to reading the capsule, this better * supports inheritance, to-Python conversions, and implicit conversions, and * it's probably also faster depending on how it's implemented. * + * ### Types with multiple bindings + * * It is valid for multiple frameworks to claim (in separate bindings) the - * same C/C++ type, or even the same Python type. (A case where multiple - * frameworks would bind the same Python type is if one is acting as an - * extension to the other, such as to support extracting pointers to - * non-primary base classes when the base framework doesn't think about - * such things.) If multiple frameworks claim the same Python type, then each - * new registrant will replace the "__pymetabind_binding__" capsule and there - * is no way to locate the other bindings from the type object. + * same C/C++ type. This supports cases where a common vocabulary type is + * bound separately in mulitple extensions in the same process. Frameworks + * are encouraged to try all registered bindings for the target type when + * they perform from-Python conversions. * - * All fields of this structure are set before it is made visible to other - * threads and then never changed, so they don't need locking to access. - * However, on free-threaded builds it is necessary to validate that the type - * object is not partway through being destroyed before you use the binding, - * and prevent such destruction from beginning until you're done. To do so, - * call `pymb_try_ref_binding()`; if it returns false, don't use the binding, - * else use it and then call `pymb_unref_binding()` when done. - * (On non-free-threaded builds, these do incref/decref to prevent destruction - * of the type from starting, but can't fail because there's no *concurrent* - * destruction hazard.) - * - * In order to work with one framework's Python objects of a certain type, other - * frameworks must be able to locate a `pymb_binding` structure for that type. - * It is expected that they will maintain their own type-to-binding maps, which - * they can keep up-to-date via their `pymb_framework::add_foreign_binding` and - * `pymb_framework::remove_foreign_binding` hooks. It is important to think very - * carefully about how to design the synchronization for these maps so that - * lookups do not return pointers to bindings that have been deallocated. - * The remainder of this comment provides some suggestions. + * If multiple frameworks claim the same Python type, the last one will + * typically win, since there is only one "__pymetabind_binding__" attribute + * on the type object and a binding is removed when its capsule is no longer + * referenced. If you're trying to do something unusual like wrapping another + * framework's binding to provide additional features, you can stash the + * extra binding(s) under a different attribute name. pymetabind never uses + * the "__pymetabind_binding__" attribute to locate the binding for its own + * purposes; it's used only to fulfill calls to `pymb_get_binding()`. * - * The recommended way to handle synchronization is to protect your type lookup - * map with a readers/writer lock. In your `remove_foreign_binding` hook, - * obtain a write lock, and hold it while removing the corresponding entry from - * the map. Before performing a type lookup, obtain a read lock. If the lookup - * succeeds, call `pymb_try_ref_binding()` on the resulting binding before - * you release your read lock. Since the binding structure can't be deallocated - * until all `remove_foreign_binding` hooks have returned, this scheme provides - * effective protection. It is important not to hold the read lock while - * executing arbitrary Python code, since a deadlock would result if the type - * object is deallocated (requiring a write lock) while the read lock were held. - * Note that `pymb_framework::from_python` for many popular frameworks is - * capable of executing arbitrary Python code to perform implicit conversions. + * ### Synchronization * - * The lock on a single shared type lookup map is a contention bottleneck, - * especially if you don't have a readers/writer lock and wish to get by with - * an ordinary mutex. To improve performance, you can give each thread its - * own lookup map, and require `remove_foreign_binding` to update all of them. - * As long as the per-thread maps are always visited in a consistent order - * when removing a binding, the splitting shouldn't introduce new deadlocks. - * Since each thread has a separate mutex for its separate map, contention - * occurs only when bindings are being added or removed, which is much less - * common than using them. + * Most fields of this structure are set before it is made visible to other + * threads and then never changed, so they don't need locking to access. The + * `link` and `capsule` are protected by the registry lock. */ struct pymb_binding { // Links to the previous and next bindings in the list of @@ -514,10 +645,19 @@ struct pymb_binding { // The framework that provides this binding struct pymb_framework* framework; + // Borrowed reference to the capsule object that refers to this binding. + // Becomes NULL in pymb_remove_binding(). + PyObject* capsule; + // Python type: you will get an instance of this type from a successful // call to `framework::from_python()` that passes this binding PyTypeObject* pytype; + // Strong reference to a weakref to `pytype`; its callback will prompt + // us to remove the binding. May be NULL if Py_TYPE(pytype)->tp_finalize + // will take care of that. + PyObject* pytype_wr; + // The native identifier for this type in `framework->abi_lang`, if that is // a concept that exists in that language. See the documentation of // `enum pymb_abi_lang` for specific per-language semantics. @@ -537,27 +677,56 @@ struct pymb_binding { void* context; }; -/* - * Users of non-C/C++ languages are welcome to replicate the logic of these - * inline functions rather than calling them. Their implementations are - * considered part of the ABI. - */ - PYMB_FUNC struct pymb_registry* pymb_get_registry(); PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, struct pymb_framework* framework); -PYMB_FUNC void pymb_remove_framework(struct pymb_registry* registry, - struct pymb_framework* framework); -PYMB_FUNC void pymb_add_binding(struct pymb_registry* registry, - struct pymb_binding* binding); -PYMB_FUNC void pymb_remove_binding(struct pymb_registry* registry, - struct pymb_binding* binding); -PYMB_FUNC int pymb_try_ref_binding(struct pymb_binding* binding); -PYMB_FUNC void pymb_unref_binding(struct pymb_binding* binding); +PYMB_FUNC void pymb_remove_framework(struct pymb_framework* framework); +PYMB_FUNC void pymb_add_binding(struct pymb_binding* binding, + int tp_finalize_will_remove); +PYMB_FUNC void pymb_remove_binding(struct pymb_binding* binding); PYMB_FUNC struct pymb_binding* pymb_get_binding(PyObject* type); #if !defined(PYMB_DECLS_ONLY) +PYMB_INLINE void pymb_registry_free(struct pymb_registry* registry) { + assert(pymb_list_is_empty(®istry->bindings) && + "some framework was removed before its bindings"); + free(registry->weakref_callback_def); + free(registry); +} + +PYMB_FUNC void pymb_registry_capsule_destructor(PyObject* capsule) { + struct pymb_registry* registry = + (struct pymb_registry*) PyCapsule_GetPointer( + capsule, "pymetabind_registry"); + if (!registry) { + PyErr_WriteUnraisable(capsule); + return; + } + registry->deallocate_when_empty = 1; + if (pymb_list_is_empty(®istry->frameworks)) { + pymb_registry_free(registry); + } +} + +PYMB_FUNC PyObject* pymb_weakref_callback(PyObject* self, PyObject* weakref) { + // self is bound using PyCFunction_New to refer to a capsule that contains + // the binding pointer (not the binding->capsule; this one has no dtor). + // `weakref` is the weakref (to the bound type) that expired. + if (!PyWeakref_CheckRefExact(weakref) || !PyCapsule_CheckExact(self)) { + PyErr_BadArgument(); + return NULL; + } + struct pymb_binding* binding = + (struct pymb_binding*) PyCapsule_GetPointer( + self, "pymetabind_binding"); + if (!binding) { + return NULL; + } + pymb_remove_binding(binding); + Py_RETURN_NONE; +} + /* * Locate an existing `pymb_registry`, or create a new one if necessary. * Returns a pointer to it, or NULL with the CPython error indicator set. @@ -588,13 +757,32 @@ PYMB_FUNC struct pymb_registry* pymb_get_registry() { if (registry) { pymb_list_init(®istry->frameworks); pymb_list_init(®istry->bindings); - capsule = PyCapsule_New(registry, "pymetabind_registry", NULL); - int rv = capsule ? PyDict_SetItem(dict, key, capsule) : -1; - Py_XDECREF(capsule); - if (rv != 0) { + registry->deallocate_when_empty = 0; + + // C doesn't allow inline functions to declare static variables, + // so allocate this on the heap + PyMethodDef* def = (PyMethodDef*) calloc(1, sizeof(PyMethodDef)); + if (!def) { free(registry); - registry = NULL; + PyErr_NoMemory(); + Py_DECREF(key); + return NULL; } + def->ml_name = "pymetabind_weakref_callback"; + def->ml_meth = pymb_weakref_callback; + def->ml_flags = METH_O; + def->ml_doc = NULL; + registry->weakref_callback_def = def; + + // Attach a destructor so the registry memory is released at teardown + capsule = PyCapsule_New(registry, "pymetabind_registry", + pymb_registry_capsule_destructor); + if (!capsule) { + free(registry); + } else if (PyDict_SetItem(dict, key, capsule) == -1) { + registry = NULL; // will be deallocated by capsule destructor + } + Py_XDECREF(capsule); } else { PyErr_NoMemory(); } @@ -609,16 +797,16 @@ PYMB_FUNC struct pymb_registry* pymb_get_registry() { */ PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, struct pymb_framework* framework) { -#if defined(Py_GIL_DISABLED) && PY_VERSION_HEX < 0x030e0000 - assert((framework->flags & pymb_framework_bindings_usable_forever) && - "Free-threaded removal of bindings requires PyUnstable_TryIncRef(), " - "which was added in CPython 3.14"); -#endif + // Defensive: ensure hook is clean before first list insertion to avoid UB + framework->link.next = NULL; + framework->link.prev = NULL; + framework->registry = registry; pymb_lock_registry(registry); PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { // Intern `abi_extra` strings so they can be compared by pointer if (other->abi_extra && framework->abi_extra && - 0 == strcmp(other->abi_extra, framework->abi_extra)) { + (other->abi_extra == framework->abi_extra || + strcmp(other->abi_extra, framework->abi_extra) == 0)) { framework->abi_extra = other->abi_extra; break; } @@ -631,30 +819,116 @@ PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, } } PYMB_LIST_FOREACH(struct pymb_binding*, binding, registry->bindings) { - if (binding->framework != framework && pymb_try_ref_binding(binding)) { + if (binding->framework != framework) { framework->add_foreign_binding(binding); - pymb_unref_binding(binding); } } pymb_unlock_registry(registry); } -/* Add a new binding to the given registry */ -PYMB_FUNC void pymb_add_binding(struct pymb_registry* registry, - struct pymb_binding* binding) { -#if defined(Py_GIL_DISABLED) && PY_VERSION_HEX >= 0x030e0000 - PyUnstable_EnableTryIncRef((PyObject *) binding->pytype); +/* + * Remove a framework from the registry it was added to. + * + * This may only be called during Python interpreter finalization. Rationale: + * other frameworks might be maintaining an entry for the removed one in their + * exception translator lists, and supporting concurrent removal of exception + * translators would add undesirable synchronization overhead to the handling + * of every exception. At finalization time there are no more threads. + * + * Once this function returns, you can free the framework structure. + * + * If a framework never removes itself, it must not claim to be `leak_safe`. + */ +PYMB_FUNC void pymb_remove_framework(struct pymb_framework* framework) { + struct pymb_registry* registry = framework->registry; + + // No need for registry lock/unlock since there are no more threads + pymb_list_unlink(&framework->link); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + other->remove_foreign_framework(framework); + } + + // Destroy registry if capsule is gone and this was the last framework + if (registry->deallocate_when_empty && + pymb_list_is_empty(®istry->frameworks)) { + pymb_registry_free(registry); + } +} + +PYMB_FUNC void pymb_binding_capsule_remove(PyObject* capsule) { + struct pymb_binding* binding = + (struct pymb_binding*) PyCapsule_GetPointer( + capsule, "pymetabind_binding"); + if (!binding) { + PyErr_WriteUnraisable(capsule); + return; + } + pymb_remove_binding(binding); +} + +/* + * Add a new binding for `binding->framework`. If `tp_finalize_will_remove` is + * nonzero, the caller guarantees that `Py_TYPE(binding->pytype).tp_finalize` + * will call `pymb_remove_binding()`; this saves some allocations compared + * to pymetabind needing to figure out when the type is destroyed on its own. + * See the comment on `pymb_binding` for more details. + */ +PYMB_FUNC void pymb_add_binding(struct pymb_binding* binding, + int tp_finalize_will_remove) { + // Defensive: ensure hook is clean before first list insertion to avoid UB + binding->link.next = NULL; + binding->link.prev = NULL; + + binding->pytype_wr = NULL; + binding->capsule = NULL; + + struct pymb_registry* registry = binding->framework->registry; + if (!tp_finalize_will_remove) { + // Different capsule than the binding->capsule, so that the callback + // doesn't keep the binding alive + PyObject* sub_capsule = PyCapsule_New(binding, "pymetabind_binding", + NULL); + if (!sub_capsule) { + goto error; + } + PyObject* callback = PyCFunction_New(registry->weakref_callback_def, + sub_capsule); + Py_DECREF(sub_capsule); // ownership transferred to callback + if (!callback) { + goto error; + } + binding->pytype_wr = PyWeakref_NewRef((PyObject *) binding->pytype, + callback); + Py_DECREF(callback); // ownership transferred to weakref + if (!binding->pytype_wr) { + goto error; + } + } else { +#if defined(Py_GIL_DISABLED) + // No callback needed in this case, but we still do need the weakref + // so that pymb_remove_binding() can tell if the type is being + // finalized or not. + binding->pytype_wr = PyWeakref_NewRef((PyObject *) binding->pytype, + NULL); + if (!binding->pytype_wr) { + goto error; + } #endif - PyObject* capsule = PyCapsule_New(binding, "pymetabind_binding", NULL); - int rv = -1; - if (capsule) { - rv = PyObject_SetAttrString((PyObject *) binding->pytype, - "__pymetabind_binding__", capsule); - Py_DECREF(capsule); } - if (rv != 0) { - PyErr_WriteUnraisable((PyObject *) binding->pytype); + + binding->capsule = PyCapsule_New(binding, "pymetabind_binding", + pymb_binding_capsule_remove); + if (!binding->capsule) { + goto error; } + if (PyObject_SetAttrString((PyObject *) binding->pytype, + "__pymetabind_binding__", + binding->capsule) != 0) { + Py_CLEAR(binding->capsule); + goto error; + } + Py_DECREF(binding->capsule); // keep only a borrowed reference + pymb_lock_registry(registry); pymb_list_append(®istry->bindings, &binding->link); PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { @@ -663,62 +937,134 @@ PYMB_FUNC void pymb_add_binding(struct pymb_registry* registry, } } pymb_unlock_registry(registry); + return; + + error: + PyErr_WriteUnraisable((PyObject *) binding->pytype); + Py_XDECREF(binding->pytype_wr); + binding->framework->free_local_binding(binding); +} + +#if defined(Py_GIL_DISABLED) +PYMB_FUNC void pymb_binding_capsule_destroy(PyObject* capsule) { + struct pymb_binding* binding = + (struct pymb_binding*) PyCapsule_GetPointer( + capsule, "pymetabind_binding"); + if (!binding) { + PyErr_WriteUnraisable(capsule); + return; + } + Py_CLEAR(binding->pytype_wr); + binding->framework->free_local_binding(binding); } +#endif /* - * Remove a binding from the given registry. This must be called during - * deallocation of the `binding->pytype`, such that its reference count is - * zero but still accessible. Once this function returns, you can free the - * binding structure. + * Remove a binding from the registry it was added to. Don't call this yourself, + * except from the tp_finalize slot of a binding's type's metatype. + * The user-servicable way to remove a binding from a still-alive type is to + * delete the capsule. The binding structure will eventually be freed by calling + * `binding->framework->free_local_binding(binding)`. */ -PYMB_FUNC void pymb_remove_binding(struct pymb_registry* registry, - struct pymb_binding* binding) { +PYMB_FUNC void pymb_remove_binding(struct pymb_binding* binding) { + struct pymb_registry* registry = binding->framework->registry; + + // Since we need to obtain it anyway, use the registry lock to serialize + // concurrent attempts to remove the same binding pymb_lock_registry(registry); + if (!binding->capsule) { + // Binding was concurrently removed from multiple places; the first + // one to get the registry lock wins. + pymb_unlock_registry(registry); + return; + } + +#if defined(Py_GIL_DISABLED) + // Determine if binding->pytype is still fully alive (not yet started + // finalizing). If so, it can't die until the next GC cycle, so freeing + // the binding at the next GC is safe. + PyObject* pytype_strong = NULL; + if (PyWeakref_GetRef(binding->pytype_wr, &pytype_strong) == -1) { + // If something's wrong with the weakref, leave pytype_strong set to + // NULL in order to conservatively assume the type is finalizing. + // This will leak the binding struct until the type object is destroyed. + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } +#endif + + // Clear the existing capsule's destructor so we don't have to worry about + // it firing after the pymb_binding struct has actually been freed. + // Note we can safely assume the capsule hasn't been freed yet, even + // though it might be mid-destruction. (Proof: Its destructor calls + // this function, which cannot complete until it acquires the lock we + // currently hold. If the destructor completed already, we would have bailed + // out above upon noticing capsule was already NULL.) + if (PyCapsule_SetDestructor(binding->capsule, NULL) != 0) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } + + // Mark this binding as being in the process of being destroyed. + binding->capsule = NULL; + + // If weakref hasn't fired yet, we don't need it anymore. Destroying it + // ensures it won't fire after the binding struct has been freed. + Py_CLEAR(binding->pytype_wr); + pymb_list_unlink(&binding->link); + binding->framework->remove_local_binding(binding); PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { if (other != binding->framework) { other->remove_foreign_binding(binding); } } pymb_unlock_registry(registry); -} - -/* - * Increase the reference count of a binding. Return 1 if successful (you can - * use the binding and must call pymb_unref_binding() when done) or 0 if the - * binding is being removed and shouldn't be used. - */ -PYMB_FUNC int pymb_try_ref_binding(struct pymb_binding* binding) { -#if defined(Py_GIL_DISABLED) - if (!(binding->framework->flags & pymb_framework_bindings_usable_forever)) { -#if PY_VERSION_HEX >= 0x030e0000 - return PyUnstable_TryIncRef((PyObject *) binding->pytype); -#else - // bindings_usable_forever is required on this Python version, and - // was checked in pymb_add_framework() - assert(false); -#endif - } -#else - Py_INCREF((PyObject *) binding->pytype); -#endif - return 1; -} -/* Decrease the reference count of a binding. */ -PYMB_FUNC void pymb_unref_binding(struct pymb_binding* binding) { -#if defined(Py_GIL_DISABLED) - if (!(binding->framework->flags & pymb_framework_bindings_usable_forever)) { -#if PY_VERSION_HEX >= 0x030e0000 - Py_DECREF((PyObject *) binding->pytype); +#if !defined(Py_GIL_DISABLED) + // On GIL builds, there's no need to delay deallocation + binding->framework->free_local_binding(binding); #else - // bindings_usable_forever is required on this Python version, and - // was checked in pymb_add_framework() - assert(false); -#endif + // Create a new capsule to manage the actual freeing + PyObject* capsule_destroy = PyCapsule_New(binding, + "pymetabind_binding", + pymb_binding_capsule_destroy); + if (!capsule_destroy) { + // Just leak the binding if we can't set up the capsule + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } else if (pytype_strong) { + // Type still alive -> embed the capsule in a cycle so it lasts until + // next GC. (The type will live at least that long.) + PyObject* list = PyList_New(2); + if (!list) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + // leak the capsule and therefore the binding + } else { + PyList_SetItem(list, 0, capsule_destroy); + PyList_SetItem(list, 1, list); + // list is now referenced only by itself and will be GCable + } + } else { + // Type is dying -> destroy the capsule when the type is destroyed. + // Since the type's weakrefs were already cleared, any weakref we add + // now won't fire until the type's tp_dealloc. We reuse our existing + // weakref callback for convenience; the call that it makes to + // pymb_remove_binding() will be a no-op, but after it fires, + // the capsule destructor will do the freeing we desire. + PyObject* callback = PyCFunction_New(registry->weakref_callback_def, + capsule_destroy); + if (!callback) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + // leak the capsule and therefore the binding + } else { + Py_DECREF(capsule_destroy); // ownership transferred to callback + binding->pytype_wr = PyWeakref_NewRef((PyObject *) binding->pytype, + callback); + Py_DECREF(callback); // ownership transferred to weakref + if (!binding->pytype_wr) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } + } } -#else - Py_DECREF((PyObject *) binding->pytype); + Py_XDECREF(pytype_strong); #endif } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aef6c70ad..a73fc9f4d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -148,8 +148,10 @@ target_include_directories(inter_module PRIVATE ${NB_DIR}/include) nanobind_add_module(test_inter_module_1_ext NB_DOMAIN mydomain test_inter_module_1.cpp ${NB_EXTRA_ARGS}) nanobind_add_module(test_inter_module_2_ext NB_DOMAIN mydomain test_inter_module_2.cpp ${NB_EXTRA_ARGS}) +nanobind_add_module(test_inter_module_foreign_ext NB_DOMAIN otherdomain test_inter_module_foreign.cpp ${NB_EXTRA_ARGS}) target_link_libraries(test_inter_module_1_ext PRIVATE inter_module) target_link_libraries(test_inter_module_2_ext PRIVATE inter_module) +target_link_libraries(test_inter_module_foreign_ext PRIVATE inter_module) set(TEST_FILES common.py diff --git a/tests/delattr_and_ensure_destroyed.h b/tests/delattr_and_ensure_destroyed.h new file mode 100644 index 000000000..c060894aa --- /dev/null +++ b/tests/delattr_and_ensure_destroyed.h @@ -0,0 +1,31 @@ +#pragma once +#include + +inline void delattr_and_ensure_destroyed(nanobind::handle scope, + const char *name) { + if (!hasattr(scope, name)) + return; + bool destroyed = false; + bool **destroyed_pp = new (bool*)(&destroyed); + nanobind::detail::keep_alive(scope.attr(name).ptr(), destroyed_pp, + [](void *ptr) noexcept { + bool **destroyed_pp = (bool **) ptr; + if (*destroyed_pp) { + **destroyed_pp = true; + } + delete destroyed_pp; + }); + delattr(scope, name); + if (!destroyed) { + auto collect = nanobind::module_::import_("gc").attr("collect"); + collect(); + if (!destroyed) { + collect(); + if (!destroyed) { + *destroyed_pp = nullptr; + nanobind::detail::raise("Couldn't delete binding for %s in %s!", + name, repr(scope).c_str()); + } + } + } +} diff --git a/tests/inter_module.cpp b/tests/inter_module.cpp index 20a1c685f..6b9caa5a5 100644 --- a/tests/inter_module.cpp +++ b/tests/inter_module.cpp @@ -4,6 +4,34 @@ Shared create_shared() { return { 123 }; } +std::shared_ptr create_shared_sp() { + return std::make_shared(create_shared()); +} + +std::unique_ptr create_shared_up() { + return std::make_unique(create_shared()); +} + bool check_shared(const Shared &shared) { return shared.value == 123; } + +bool check_shared_sp(std::shared_ptr shared) { + return shared->value == 123; +} + +bool check_shared_up(std::unique_ptr shared) { + return shared->value == 123; +} + +SharedEnum create_enum() { + return SharedEnum::Two; +} + +bool check_enum(SharedEnum e) { + return e == SharedEnum::Two; +} + +void throw_shared() { + throw create_shared(); +} diff --git a/tests/inter_module.h b/tests/inter_module.h index c78498a6a..03b702cd8 100644 --- a/tests/inter_module.h +++ b/tests/inter_module.h @@ -1,4 +1,5 @@ #include +#include #if defined(SHARED_BUILD) # define EXPORT_SHARED NB_EXPORT @@ -10,5 +11,16 @@ struct EXPORT_SHARED Shared { int value; }; +enum class EXPORT_SHARED SharedEnum { One = 1, Two = 2 }; + extern EXPORT_SHARED Shared create_shared(); +extern EXPORT_SHARED std::shared_ptr create_shared_sp(); +extern EXPORT_SHARED std::unique_ptr create_shared_up(); extern EXPORT_SHARED bool check_shared(const Shared &shared); +extern EXPORT_SHARED bool check_shared_sp(std::shared_ptr shared); +extern EXPORT_SHARED bool check_shared_up(std::unique_ptr shared); + +extern EXPORT_SHARED SharedEnum create_enum(); +extern EXPORT_SHARED bool check_enum(SharedEnum e); + +extern EXPORT_SHARED void throw_shared(); diff --git a/tests/test_inter_module.py b/tests/test_inter_module.py index 97f618710..735738959 100644 --- a/tests/test_inter_module.py +++ b/tests/test_inter_module.py @@ -1,8 +1,17 @@ +import gc +import sys +import time +import threading +import weakref + import test_inter_module_1_ext as t1 import test_inter_module_2_ext as t2 +import test_inter_module_foreign_ext as tf import test_classes_ext as t3 import pytest -from common import xfail_on_pypy_darwin +from common import xfail_on_pypy_darwin, parallelize + +free_threaded = hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled() @xfail_on_pypy_darwin def test01_inter_module(): @@ -11,3 +20,379 @@ def test01_inter_module(): with pytest.raises(TypeError) as excinfo: assert t3.check_shared(s) assert 'incompatible function arguments' in str(excinfo.value) + + +# t1 and t2 are in the same nanobind domain. t2 has a binding for Shared. +# tf is in a separate domain. It has a binding for Shared that appears to be +# from a foreign framework (not nanobind). It also has a create_nb_binding() +# method that will create a nanobind binding for Shared in its domain. +# So in total we have three separate pytypes that bind the C++ type Shared, +# and two domains that might need to accept them. + +# NB: there is some potential for different test cases to interfere with +# each other: we can't un-register a framework once it's registered and we +# can't undo automatic import/export all once they're requested. The ordering +# of these tests is therefore important. They work standalone and they work +# when run all in one process, but they might not work in a different order. + + +@pytest.fixture +def clean(): + tf.remove_all_bindings() + if sys.implementation.name == "pypy": + gc.collect() + yield + tf.remove_all_bindings() + if sys.implementation.name == "pypy": + gc.collect() + + +def test02_interop_exceptions_without_registration(): + # t2 defines the exception translator for Shared. Since the t1/t2 domain + # hasn't taken any interop actions yet, it hasn't registered with pymetabind + # and tf won't be able to use that translator. + with pytest.raises(SystemError, match="exception could not be translated"): + tf.throw_shared() + + # t1 can use t2's translator though, since they're in the same domain + with pytest.raises(ValueError, match="Shared.123"): + t1.throw_shared() + + +def expect(from_mod, to_mod, pattern, **extra): + outcomes = {} + extra_info = {} + + for thing in ("shared", "shared_sp", "shared_up", "enum"): + print(thing) + create = getattr(from_mod, f"create_{thing}") + check = getattr(to_mod, f"check_{thing}") + try: + obj = create() + except Exception as ex: + outcomes[thing] = None + extra_info[thing] = ex + continue + try: + ok = check(obj) + except Exception as ex: + outcomes[thing] = False + extra_info[thing] = ex + continue + assert ok, "instance appears corrupted" + outcomes[thing] = True + + expected = {} + if pattern == "local": + expected = { + "shared": True, "shared_sp": True, "shared_up": True, "enum": True + } + elif pattern == "foreign": + expected = { + "shared": True, "shared_sp": True, "shared_up": False, "enum": True + } + elif pattern == "isolated": + expected = { + "shared": False, "shared_sp": False, "shared_up": False, "enum": False + } + else: + assert False, "unknown pattern" + expected.update(extra) + assert outcomes == expected + + +def test03_interop_unimported(clean): + expect(tf, tf, "foreign", enum=None) + expect(t1, t2, "local") + expect(t1, tf, "isolated") + expect(tf, t2, "isolated", enum=None) + + # Just an export isn't enough; you need an import too + t2.export_for_interop(t2.Shared) + expect(tf, t2, "isolated", enum=None) + + +def test04_interop_import_export_errors(): + with pytest.raises( + RuntimeError, match="does not define a __pymetabind_binding__" + ): + t2.import_for_interop(tf.Convertible) + + with pytest.raises( + RuntimeError, match="not a nanobind class or enum bound in this domain" + ): + tf.export_for_interop(t2.Shared) + + with pytest.raises( + RuntimeError, match="not a nanobind class or enum bound in this domain" + ): + tf.export_for_interop(t2.SharedEnum) + + t2.export_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + t2.export_for_interop(t2.Shared) # should be idempotent + t2.export_for_interop(t2.SharedEnum) + + with pytest.raises( + RuntimeError, match="is already bound by this nanobind domain" + ): + t2.import_for_interop(t2.Shared) + + +def test05_interop_exceptions(): + # Once t2 registers with pymetabind, which happens as soon as it imports + # or exports anything, tf can translate its exceptions. + t2.export_for_interop(t2.Shared) + with pytest.raises(ValueError, match="Shared.123"): + tf.throw_shared() + + +def test06_interop_with_cpp(clean): + # Export t2.Shared to tf, but not the enum yet, and not from tf to t2 + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + expect(t1, tf, "foreign", enum=False) + expect(tf, t2, "isolated", enum=None) + + # Now export t2.SharedEnum too. Note that tf doesn't have its own + # definition of SharedEnum, so it will use the imported one and create + # t2.SharedEnums. + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Enable automatic import in the t1/t2 domain. Still doesn't help with + # tf->t1/t2 since tf.Shared is not a C++ type. + t1.import_all() + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + +def test07_interop_import_explicit_errors(clean): + with pytest.raises(RuntimeError, match=r"is not written in C\+\+"): + t2.import_for_interop(tf.RawShared) + + t2.import_for_interop_explicit(tf.RawShared) + t2.import_for_interop_explicit(tf.RawShared) + + with pytest.raises(RuntimeError, match=r"was already mapped to C\+\+ type"): + t2.import_for_interop_wrong_type(tf.RawShared) + + +def test08_interop_with_c(clean): + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + t2.import_for_interop_explicit(tf.RawShared) + + # Now that tf.RawShared is imported to t1/t2, everything should work. + expect(t1, tf, "foreign") + expect(tf, t2, "foreign") + + +@pytest.mark.skipif( + sys.implementation.name == "pypy", reason="can't GC type object on pypy" +) +def test09_remove_binding(clean): + t2.import_for_interop_explicit(tf.RawShared) + + # Remove the binding for tf.RawShared. We expect the t1/t2 domain will + # notice the removal and automatically forget about the defunct binding. + tf.remove_raw_binding() + tf.create_raw_binding() + + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + if not free_threaded: + # More binding removal tests. These only work on non-freethreading + # builds because nanobind immortalizes all its types on FT builds. + t2.remove_bindings() + t2.create_bindings() + + expect(t1, tf, "isolated") + expect(tf, t2, "isolated", enum=None) + + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Removing the binding capsule should work just as well as removing + # the type object. + del t2.Shared.__pymetabind_binding__ + del t2.SharedEnum.__pymetabind_binding__ + + expect(t1, tf, "isolated") + expect(tf, t2, "isolated", enum=None) + + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Re-import RawShared and now everything works again. + t2.import_for_interop_explicit(tf.RawShared) + expect(t1, tf, "foreign") + expect(tf, t2, "foreign") + + # Removing the binding capsule should work just as well as removing + # the type object. + del tf.RawShared.__pymetabind_binding__ + tf.export_raw_binding() + + # tf.RawShared was removed from the beginning of tf's list for Shared + # and re-added on the end; also remove and re-add t2.Shared so that + # tf.create_shared() returns a tf.RawShared + del t2.Shared.__pymetabind_binding__ + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Re-import RawShared and now everything works again. + t2.import_for_interop_explicit(tf.RawShared) + expect(t1, tf, "foreign") + expect(tf, t2, "foreign") + + +def test10_access_binding_concurrently(clean): + any_failed = False + + def repeatedly_attempt_conversions(): + deadline = time.time() + 1 + while time.time() < deadline: + try: + assert tf.check_shared(tf.create_shared()) + except: + nonlocal any_failed + any_failed = True + raise + + parallelize(repeatedly_attempt_conversions, n_threads=8) + assert not any_failed + + +@pytest.mark.skipif(not free_threaded, reason="not relevant on non-FT") +@pytest.mark.parametrize("multi", (False, True)) +def test11_remove_binding_concurrently(clean, multi): + transitions = 0 + limit = 5000 + + if multi: + # In 'multi' mode, we add more exports so the `tf` domain exercises + # the linked-list-of-foreign-bindings logic + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + + def repeatedly_remove_and_readd(): + nonlocal transitions + try: + while transitions < limit: + del tf.RawShared.__pymetabind_binding__ + tf.export_raw_binding() + if multi: + del t2.Shared.__pymetabind_binding__ + # The actual destruction of the capsule may be slightly + # delayed since it was created on a different thread. + # nanobind won't export a binding for a type that it thinks + # already has one. Retry export until the capsule shows up. + for _ in range(10): + t2.export_for_interop(t2.Shared) + if hasattr(t2.Shared, "__pymetabind_binding__"): + break + time.sleep(0.001) + else: + assert False, "binding removal was too delayed" + tf.import_for_interop(t2.Shared) + transitions += 1 + except: + transitions = limit + raise + + thread = threading.Thread(target=repeatedly_remove_and_readd) + thread.start() + + num_failed = 0 + num_successful = 0 + + def repeatedly_attempt_conversions(): + nonlocal num_failed + nonlocal num_successful + while transitions < limit: + try: + tf.check_shared(tf.create_shared()) + except TypeError: + num_failed += 1 + else: + num_successful += 1 + + try: + parallelize(repeatedly_attempt_conversions, n_threads=8) + finally: + transitions = limit + thread.join() + + # typical numbers from my machine: with limit=100, the test takes 6sec, + # and num_failed and num_successful are each several 10k's + print(num_failed, num_successful) + assert num_successful > 0 + assert num_failed > 0 or not free_threaded + + +def test12_multi_and_implicit(clean): + # Create three different types of pyobject, all of which have C++ type Shared + s1 = t1.create_shared() + sf_raw = tf.create_shared() + tf.create_nb_binding() + sf_nb = tf.create_shared() + + assert type(s1) is t2.Shared + assert type(sf_raw) is tf.RawShared + assert type(sf_nb) is tf.NbShared + + # Test automatic import/export all + t1.export_all() + tf.import_all() + + # Test implicit conversions from foreign types + for obj in (sf_nb, sf_raw, s1): + val = tf.test_implicit(obj) + assert val.value == 123 + + # We should only be sharing in the t1->tf direction, not vice versa + assert tf.check_shared(s1) + assert tf.check_shared(sf_raw) + assert tf.check_shared(sf_nb) + with pytest.raises(TypeError): + t2.check_shared(sf_raw) + with pytest.raises(TypeError): + t2.check_shared(sf_nb) + + # Now add the other direction + t1.import_all() + tf.export_all() + assert t2.check_shared(sf_raw) + assert t2.check_shared(sf_nb) + + # Test normally passing these various objects + for mod in (t2, tf): + for obj in (s1, sf_raw, sf_nb): + assert mod.check_shared(obj) + assert mod.check_shared_sp(obj) + for obj in (t1.create_enum(), tf.create_enum()): + assert mod.check_enum(obj) diff --git a/tests/test_inter_module_1.cpp b/tests/test_inter_module_1.cpp index edf4dad0e..2fcf294c0 100644 --- a/tests/test_inter_module_1.cpp +++ b/tests/test_inter_module_1.cpp @@ -1,8 +1,16 @@ #include +#include +#include #include "inter_module.h" namespace nb = nanobind; NB_MODULE(test_inter_module_1_ext, m) { m.def("create_shared", &create_shared); + m.def("create_shared_sp", &create_shared_sp); + m.def("create_shared_up", &create_shared_up); + m.def("create_enum", &create_enum); + m.def("throw_shared", &throw_shared); + m.def("export_all", []() { nb::interoperate_by_default(true, false); }); + m.def("import_all", []() { nb::interoperate_by_default(false, true); }); } diff --git a/tests/test_inter_module_2.cpp b/tests/test_inter_module_2.cpp index 3e0a7785b..cd00a60ea 100644 --- a/tests/test_inter_module_2.cpp +++ b/tests/test_inter_module_2.cpp @@ -1,9 +1,52 @@ #include +#include +#include #include "inter_module.h" +#include "delattr_and_ensure_destroyed.h" namespace nb = nanobind; +struct Other {}; + NB_MODULE(test_inter_module_2_ext, m) { - nb::class_(m, "Shared"); + m.def("create_bindings", [hm = nb::handle(m)]() { + nb::class_(hm, "Shared"); + nb::enum_(hm, "SharedEnum") + .value("One", SharedEnum::One) + .value("Two", SharedEnum::Two); + }); + m.attr("create_bindings")(); + + m.def("remove_bindings", [hm = nb::handle(m)]() { +#if !defined(NB_FREE_THREADED) // nanobind types are currently immortal in FT + delattr_and_ensure_destroyed(hm, "Shared"); + delattr_and_ensure_destroyed(hm, "SharedEnum"); +#else + (void) hm; +#endif + }); + m.def("check_shared", &check_shared); + m.def("check_shared_sp", &check_shared_sp); + m.def("check_shared_up", &check_shared_up); + m.def("check_enum", &check_enum); + + nb::register_exception_translator( + [](const std::exception_ptr &p, void *) { + try { + std::rethrow_exception(p); + } catch (const Shared &s) { + // Instead of just calling PyErr_SetString, exercise the + // path where one translator throws an exception to be handled + // by another. + throw std::range_error( + nb::str("Shared({})").format(s.value).c_str()); + } + }); + m.def("throw_shared", &throw_shared); + + m.def("export_for_interop", &nb::export_for_interop); + m.def("import_for_interop", &nb::import_for_interop<>); + m.def("import_for_interop_explicit", &nb::import_for_interop); + m.def("import_for_interop_wrong_type", &nb::import_for_interop); } diff --git a/tests/test_inter_module_foreign.cpp b/tests/test_inter_module_foreign.cpp new file mode 100644 index 000000000..7bb34bcb5 --- /dev/null +++ b/tests/test_inter_module_foreign.cpp @@ -0,0 +1,229 @@ +#include +#include +#include +#include +#include "inter_module.h" +#include "delattr_and_ensure_destroyed.h" +#include "../src/pymetabind.h" + +namespace nb = nanobind; + +// The following is a manual binding to `struct Shared`, created using the +// CPython C API only. + +struct raw_shared_instance { + PyObject_HEAD + uintptr_t spacer[2]; // ensure instance layout differs from nanobind's + bool deallocate; + Shared *ptr; + Shared value; + PyObject *weakrefs; +}; + +static void Shared_dealloc(struct raw_shared_instance *self) { + if (self->spacer[0] != 0x5a5a5a5a || self->spacer[1] != 0xa5a5a5a5) + nb::detail::fail("instance corrupted"); + if (self->weakrefs) + PyObject_ClearWeakRefs((PyObject *) self); + if (self->deallocate) + free(self->ptr); + + PyTypeObject *tp = Py_TYPE((PyObject *) self); + PyObject_Free(self); + Py_DECREF(tp); +} + +static PyObject *Shared_new(PyTypeObject *type, Shared *value, pymb_rv_policy rvp) { + struct raw_shared_instance *self; + self = PyObject_New(raw_shared_instance, type); + if (self) { + memset((char *) self + sizeof(PyObject), 0, + sizeof(*self) - sizeof(PyObject)); + self->spacer[0] = 0x5a5a5a5a; + self->spacer[1] = 0xa5a5a5a5; + switch (rvp) { + case pymb_rv_policy_take_ownership: + self->ptr = value; + self->deallocate = true; + break; + case pymb_rv_policy_copy: + case pymb_rv_policy_move: + memcpy(&self->value, value, sizeof(Shared)); + self->ptr = &self->value; + self->deallocate = false; + break; + case pymb_rv_policy_reference: + case pymb_rv_policy_reference_internal: + self->ptr = value; + self->deallocate = false; + break; + default: + nb::detail::fail("unhandled rvp %d", (int) rvp); + break; + } + } + return (PyObject *) self; +} + +static int Shared_init(struct raw_shared_instance *, PyObject *, PyObject *) { + PyErr_SetString(PyExc_TypeError, "cannot be constructed from Python"); + return -1; +} + +// And a minimal implementation for our "foreign framework" of the pymetabind +// interface, so nanobind can use raw_shared_instances. + +static void *hook_from_python(pymb_binding *binding, + PyObject *pyobj, + uint8_t, + void (*)(void *ctx, PyObject *obj), + void *) noexcept { + if (binding->pytype != Py_TYPE(pyobj)) + return nullptr; + return ((raw_shared_instance *) pyobj)->ptr; +} + +static PyObject *hook_to_python(pymb_binding *binding, + void *cobj, + enum pymb_rv_policy rvp, + PyObject *) noexcept { + if (rvp == pymb_rv_policy_none) + return nullptr; + return Shared_new(binding->pytype, (Shared *) cobj, rvp); +} + +static int hook_keep_alive(PyObject *, void *, void (*)(void*)) noexcept { + PyErr_SetString(PyExc_RuntimeError, "keep_alive not supported"); + return -1; +} + +static void hook_ignore_foreign_binding(pymb_binding *) noexcept {} +static void hook_ignore_foreign_framework(pymb_framework *) noexcept {} + +NB_MODULE(test_inter_module_foreign_ext, m) { + static PyMemberDef Shared_members[] = { + {"__weaklistoffset__", T_PYSSIZET, + offsetof(struct raw_shared_instance, weakrefs), READONLY, nullptr}, + {nullptr, 0, 0, 0, nullptr}, + }; + static PyType_Slot Shared_slots[] = { + {Py_tp_doc, (void *) "Shared object"}, + {Py_tp_init, (void *) Shared_init}, + {Py_tp_dealloc, (void *) Shared_dealloc}, + {Py_tp_members, (void *) Shared_members}, + {0, nullptr}, + }; + static PyType_Spec Shared_spec = { + /* name */ "test_inter_module_foreign_ext.RawShared", + /* basicsize */ sizeof(struct raw_shared_instance), + /* itemsize */ 0, + /* flags */ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + /* slots */ Shared_slots, + }; + + static auto *registry = pymb_get_registry(); + if (!registry) + throw nb::python_error(); + + static auto *fw = new pymb_framework{}; + fw->name = "example framework for nanobind tests"; + fw->flags = pymb_framework_leak_safe; + fw->abi_lang = pymb_abi_lang_c; + fw->from_python = hook_from_python; + fw->to_python = hook_to_python; + fw->keep_alive = hook_keep_alive; + fw->remove_local_binding = [](pymb_binding *) noexcept {}; + fw->free_local_binding = [](pymb_binding *binding) noexcept { + delete binding; + }; + fw->add_foreign_binding = hook_ignore_foreign_binding; + fw->remove_foreign_binding = hook_ignore_foreign_binding; + fw->add_foreign_framework = hook_ignore_foreign_framework; + fw->remove_foreign_framework = hook_ignore_foreign_framework; + + pymb_add_framework(registry, fw); + int res = Py_AtExit(+[]() { + pymb_remove_framework(fw); + delete fw; + }); + if (res != 0) + throw nb::python_error(); + + m.def("export_raw_binding", [hm = nb::handle(m)]() { + auto type = hm.attr("RawShared"); + auto *binding = new pymb_binding{}; + binding->framework = fw; + binding->pytype = (PyTypeObject *) type.ptr(); + binding->source_name = "Shared"; + pymb_add_binding(binding, /* tp_finalize_will_remove */ 0); + nb::import_for_interop(type); + }); + + m.def("create_raw_binding", [hm = nb::handle(m)]() { + auto *type = (PyTypeObject *) PyType_FromSpec(&Shared_spec); + if (!type) + throw nb::python_error(); +#if PY_VERSION_HEX < 0x03090000 + // __weaklistoffset__ member wasn't parsed until 3.9 + type->tp_weaklistoffset = offsetof(struct raw_shared_instance, weakrefs); +#endif + hm.attr("RawShared") = nb::steal(type); + hm.attr("export_raw_binding")(); + }); + m.attr("create_raw_binding")(); + + m.def("remove_raw_binding", [hm = nb::handle(m)]() { + delattr_and_ensure_destroyed(hm, "RawShared"); + }); + + m.def("create_nb_binding", [hm = nb::handle(m)]() { + nb::class_(hm, "NbShared"); + }); + + m.def("import_for_interop", &nb::import_for_interop<>); + m.def("export_for_interop", &nb::export_for_interop); + m.def("import_all", []() { + nb::interoperate_by_default(false, true); + }); + m.def("export_all", []() { + nb::interoperate_by_default(true, false); + }); + + m.def("remove_all_bindings", [hm = nb::handle(m)]() { + // NB: this is not a general purpose solution; the bindings removed + // here won't be re-added if `import_all` is called + nb::list bound; + pymb_lock_registry(registry); + PYMB_LIST_FOREACH(struct pymb_binding*, binding, registry->bindings) { + bound.append(nb::borrow(binding->pytype)); + } + pymb_unlock_registry(registry); + for (auto type : bound) { + nb::delattr(type, "__pymetabind_binding__"); + } + + // Restore the ability for our own create_shared() etc to work + // properly, since that's a foreign type relationship too + hm.attr("export_raw_binding")(); + nb::import_for_interop(hm.attr("RawShared")); + }); + + m.def("create_shared", &create_shared); + m.def("create_shared_sp", &create_shared_sp); + m.def("create_shared_up", &create_shared_up); + m.def("create_enum", &create_enum); + m.def("check_shared", &check_shared); + m.def("check_shared_sp", &check_shared_sp); + m.def("check_shared_up", &check_shared_up); + m.def("check_enum", &check_enum); + m.def("throw_shared", &throw_shared); + + struct Convertible { int value; }; + nb::class_(m, "Convertible") + .def("__init__", [](Convertible *self, const Shared &arg) { + new (self) Convertible{arg.value}; + }) + .def_ro("value", &Convertible::value); + nb::implicitly_convertible(); + m.def("test_implicit", [](Convertible conv) { return conv; }); +} From 245fa5a5e502978680e830c3ed21c5000569d61b Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Sun, 14 Sep 2025 02:37:27 -0600 Subject: [PATCH 7/8] Add not_foreign cast flag and nb_type_put() allow_foreign flag so we can prevent mutual recursion between two frameworks failing to perform a cast. Simplify enum destruction. Clean up some things I noticed while updating the pybind11 PR. --- include/nanobind/nb_attr.h | 5 ++++- include/nanobind/nb_class.h | 3 --- include/nanobind/nb_lib.h | 3 ++- include/nanobind/stl/unique_ptr.h | 14 ++++++-------- src/nb_enum.cpp | 11 +++++++---- src/nb_foreign.cpp | 29 +++++++++++++++------------- src/nb_internals.h | 32 +++++++++++++++++++------------ src/nb_type.cpp | 17 ++++++++-------- tests/test_inter_module.py | 11 +++++++---- 9 files changed, 71 insertions(+), 54 deletions(-) diff --git a/include/nanobind/nb_attr.h b/include/nanobind/nb_attr.h index a69df8693..807344ecb 100644 --- a/include/nanobind/nb_attr.h +++ b/include/nanobind/nb_attr.h @@ -207,7 +207,10 @@ enum cast_flags : uint8_t { // This implies that objects added to the cleanup list may be // released immediately after the caster's final output value is // obtained, i.e., before it is used. - manual = (1 << 3) + manual = (1 << 3), + + // Disallow satisfying this cast with a foreign framework's binding + not_foreign = (1 << 4), }; diff --git a/include/nanobind/nb_class.h b/include/nanobind/nb_class.h index 347f5c674..b9d4cc869 100644 --- a/include/nanobind/nb_class.h +++ b/include/nanobind/nb_class.h @@ -205,9 +205,6 @@ enum class enum_flags : uint32_t { /// Is the underlying enumeration type Flag? is_flag = (1 << 3), - - /// Was the enum successfully registered with nanobind? - is_registered = (1 << 4), }; struct enum_init_data { diff --git a/include/nanobind/nb_lib.h b/include/nanobind/nb_lib.h index 187c26bb3..856475580 100644 --- a/include/nanobind/nb_lib.h +++ b/include/nanobind/nb_lib.h @@ -289,7 +289,8 @@ NB_CORE bool nb_type_get(const std::type_info *t, PyObject *o, uint8_t flags, /// Cast a C++ type instance into a Python object NB_CORE PyObject *nb_type_put(const std::type_info *cpp_type, void *value, rv_policy rvp, cleanup_list *cleanup, - bool *is_new = nullptr) noexcept; + bool *is_new = nullptr, + bool allow_foreign = true) noexcept; // Special version of nb_type_put for polymorphic classes NB_CORE PyObject *nb_type_put_p(const std::type_info *cpp_type, diff --git a/include/nanobind/stl/unique_ptr.h b/include/nanobind/stl/unique_ptr.h index 7186093a4..7f8eccd1f 100644 --- a/include/nanobind/stl/unique_ptr.h +++ b/include/nanobind/stl/unique_ptr.h @@ -94,14 +94,12 @@ struct type_caster> { // Stash source python object src = src_; - // Don't accept foreign types; they can't relinquish ownership - if (!src.is_none() && !inst_check(src)) - return false; - - /* Try casting to a pointer of the underlying type. We pass flags=0 and - cleanup=nullptr to prevent implicit type conversions (they are - problematic since the instance then wouldn't be owned by 'src') */ - return caster.from_python(src_, 0, nullptr); + /* Try casting to a pointer of the underlying type. We pass + cleanup=nullptr and !(flags & convert) to prevent implicit type + conversions, which are problematic since the instance then wouldn't + be owned by 'src'. Also disable casting from a foreign type since it + wouldn't be able to relinquish ownership. */ + return caster.from_python(src_, (uint8_t) cast_flags::not_foreign, nullptr); } template diff --git a/src/nb_enum.cpp b/src/nb_enum.cpp index 11ab12263..7029af2f4 100644 --- a/src/nb_enum.cpp +++ b/src/nb_enum.cpp @@ -73,8 +73,6 @@ PyObject *enum_create(enum_init_data *ed) noexcept { type_init_data *t = (type_init_data *) p; delete (enum_map *) t->enum_tbl.fwd; delete (enum_map *) t->enum_tbl.rev; - if (t->flags & (uint32_t) enum_flags::is_registered) - nb_type_unregister(t); free((char*) t->name); delete t; }); @@ -88,7 +86,12 @@ PyObject *enum_create(enum_init_data *ed) noexcept { return tp; } - t->flags |= (uint32_t) enum_flags::is_registered; + // Unregister the enum type when it begins being finalized + keep_alive(result.ptr(), t, [](void *p) noexcept { + nb_type_unregister((type_init_data *) p); + }); + + // Delete typeinfo only when the type's dict is cleared result.attr("__nb_enum__") = tie_lifetimes; make_immortal(result.ptr()); @@ -180,7 +183,7 @@ bool enum_from_python(const std::type_info *tp, #if !defined(NB_DISABLE_INTEROP) auto try_foreign = [=, &has_foreign]() -> bool { - if (has_foreign) { + if (has_foreign && !(flags & (uint8_t) cast_flags::not_foreign)) { void *ptr = nb_type_get_foreign(internals, tp, o, flags, cleanup); if (ptr) { // Copy from the C++ enum object to our output integer. diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp index 604f93fd0..addf873a6 100644 --- a/src/nb_foreign.cpp +++ b/src/nb_foreign.cpp @@ -47,13 +47,14 @@ static void *nb_foreign_from_python(pymb_binding *binding, PyObject *obj), void *keep_referenced_ctx) noexcept { cleanup_list cleanup{nullptr}; + uint8_t flags = (uint8_t) cast_flags::not_foreign; + if (convert) + flags |= (uint8_t) cast_flags::convert; auto *td = (type_data *) binding->context; if (td->align == 0) { // enum int64_t value; if (keep_referenced && - enum_from_python(td->type, pyobj, &value, td->size, - convert ? uint8_t(cast_flags::convert) : 0, - nullptr)) { + enum_from_python(td->type, pyobj, &value, td->size, flags, nullptr)) { bytes holder{(uint8_t *) &value + NB_BIG_ENDIAN * (8 - td->size), td->size}; keep_referenced(keep_referenced_ctx, holder.ptr()); @@ -63,8 +64,7 @@ static void *nb_foreign_from_python(pymb_binding *binding, } void *result = nullptr; - bool ok = nb_type_get(td->type, pyobj, - convert ? uint8_t(cast_flags::convert) : 0, + bool ok = nb_type_get(td->type, pyobj, flags, keep_referenced ? &cleanup : nullptr, &result); if (keep_referenced) { // Move temporary references from our `cleanup_list` to our caller's @@ -109,7 +109,8 @@ static PyObject *nb_foreign_to_python(pymb_binding *binding, // unless a pyobject wrapper already exists. rvp = rv_policy::none; } - return nb_type_put(td->type, cobj, rvp, &cleanup, nullptr); + return nb_type_put(td->type, cobj, rvp, &cleanup, + /* is_new */ nullptr, /* allow_foreign */ false); } static int nb_foreign_keep_alive(PyObject *nurse, @@ -153,10 +154,12 @@ static int nb_foreign_translate_exception(void *eptr) noexcept { std::rethrow_exception(e); } catch (python_error &e) { e.restore(); + return 1; } catch (builtin_exception &e) { if (!set_builtin_exception_status(e)) PyErr_SetString(PyExc_SystemError, "foreign function threw " "nanobind::next_overload()"); + return 1; } catch (...) { e = std::current_exception(); } return 0; } @@ -521,10 +524,10 @@ void *nb_type_try_foreign(nb_internals *internals_, #if defined(NB_FREE_THREADED) auto per_thread_guard = nb_type_lock_c2p_fast(internals_); nb_type_map_fast &type_c2p_fast = *per_thread_guard; - uint32_t updates_count = per_thread_guard.updates_count(); #else nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; #endif + uint32_t update_count = type_c2p_fast.update_count; do { // We assume nb_type_c2p already ran for this type, so that there's // no need to handle a cache miss here. @@ -563,16 +566,16 @@ void *nb_type_try_foreign(nb_internals *internals_, return result; #if defined(NB_FREE_THREADED) - // Re-acquire lock to continue iteration. If we missed an - // update while the lock was released, start our lookup over - // in case the update removed the node we're on. + // Re-acquire lock to continue iteration per_thread_guard = nb_type_lock_c2p_fast(internals_); - if (per_thread_guard.updates_count() != updates_count) { +#endif + // If we missed an update during attempt(), start our lookup + // over in case the update removed the node we're on. + if (type_c2p_fast.update_count != update_count) { // Concurrent update occurred; retry - updates_count = per_thread_guard.updates_count(); + update_count = type_c2p_fast.update_count; break; } -#endif } current = current->next; } diff --git a/src/nb_internals.h b/src/nb_internals.h index ec503768e..e7406dd06 100644 --- a/src/nb_internals.h +++ b/src/nb_internals.h @@ -331,6 +331,7 @@ struct nb_type_map_fast { /// then return a reference to the stored value, which the caller may /// modify. void*& lookup_or_set(const std::type_info *ti, void *dflt) { + ++update_count; return data.try_emplace((void *) ti, dflt).first.value(); } @@ -348,11 +349,23 @@ struct nb_type_map_fast { auto it = data.find((void *) ti); if (it != data.end()) { it.value() = value; + ++update_count; return true; } return false; } + /// Number of times the map has been modified. Used in nb_type_try_foreign() + /// to detect cases where attempting to use one foreign binding for a type + /// may have invalidated the iterator needed to advance to the next one. + uint32_t update_count = 0; + +#if defined(NB_FREE_THREADED) + /// Mutex used by `nb_type_map_per_thread`, stored here because it fits + /// in padding this way. + PyMutex mutex{}; +#endif + private: // Use a generic ptr->ptr map to avoid needing another instantiation of // robin_map. Keys are const std::type_info*. See TYPE MAPPING above for @@ -390,35 +403,30 @@ struct nb_type_map_per_thread { } ~guard() { if (parent) - PyMutex_Unlock(&parent->mutex); + PyMutex_Unlock(&parent->map.mutex); } nb_type_map_fast& operator*() const { return parent->map; } nb_type_map_fast* operator->() const { return &parent->map; } - uint32_t updates_count() const { return parent->updates; } - void note_updated() { ++parent->updates; } - private: friend nb_type_map_per_thread; explicit guard(nb_type_map_per_thread &parent_) : parent(&parent_) { - PyMutex_Lock(&parent->mutex); + PyMutex_Lock(&parent->map.mutex); } nb_type_map_per_thread *parent = nullptr; }; guard lock() { return guard{*this}; } - // Mutex protecting accesses to `updates` and `map` - PyMutex mutex{}; - - // The number of times `map` has been modified - uint32_t updates = 0; - nb_internals &internals; + + private: + // Access to the map is only possible via `guard`, which holds a lock nb_type_map_fast map; + public: // In order to access or modify `next`, you must hold the nb_internals mutex - // (this->mutex is not needed for iteration) + // (this->map.mutex is not needed for iteration) nb_type_map_per_thread *next = nullptr; }; #endif diff --git a/src/nb_type.cpp b/src/nb_type.cpp index d0412a3bc..c7c1f87d0 100644 --- a/src/nb_type.cpp +++ b/src/nb_type.cpp @@ -472,12 +472,9 @@ void nb_type_update_c2p_fast(const std::type_info *type, void *value) noexcept { for (nb_type_map_per_thread *cache = internals_->type_c2p_per_thread_head; cache; cache = cache->next) { - auto guard = cache->lock(); - bool found = nb_type_update_cache(*guard, it_alias->first, - (nb_alias_seq *) it_alias->second, - value); - if (found) - guard.note_updated(); + nb_type_update_cache(*cache->lock(), it_alias->first, + (nb_alias_seq *) it_alias->second, + value); } // We can't require that we found a match, because the type might // have been cached only by a thread that has since exited. @@ -1621,6 +1618,9 @@ void *nb_type_get_foreign(nb_internals *internals_, PyObject *src, uint8_t flags, cleanup_list *cleanup) noexcept { + if (flags & (uint8_t) cast_flags::not_foreign) + return nullptr; + struct capture { PyObject *src; uint8_t flags; @@ -1993,7 +1993,8 @@ PyObject *nb_type_put_foreign(nb_internals *internals_, PyObject *nb_type_put(const std::type_info *cpp_type, void *value, rv_policy rvp, cleanup_list *cleanup, - bool *is_new) noexcept { + bool *is_new, + bool allow_foreign) noexcept { // Convert nullptr -> None if (!value) { Py_INCREF(Py_None); @@ -2016,7 +2017,7 @@ PyObject *nb_type_put(const std::type_info *cpp_type, #if !defined(NB_DISABLE_INTEROP) auto try_foreign = [=, &has_foreign]() -> PyObject* { - if (has_foreign) + if (has_foreign && allow_foreign) return nb_type_put_foreign(internals_, cpp_type, nullptr, value, rvp, cleanup, is_new); return nullptr; diff --git a/tests/test_inter_module.py b/tests/test_inter_module.py index 735738959..41b1b5ba6 100644 --- a/tests/test_inter_module.py +++ b/tests/test_inter_module.py @@ -347,11 +347,12 @@ def repeatedly_attempt_conversions(): transitions = limit thread.join() - # typical numbers from my machine: with limit=100, the test takes 6sec, - # and num_failed and num_successful are each several 10k's + # typical numbers from my machine: with limit=5000, the test takes a + # decent fraction of a second, and num_failed and num_successful are each + # several 10k's print(num_failed, num_successful) assert num_successful > 0 - assert num_failed > 0 or not free_threaded + assert num_failed > 0 def test12_multi_and_implicit(clean): @@ -386,8 +387,10 @@ def test12_multi_and_implicit(clean): # Now add the other direction t1.import_all() tf.export_all() - assert t2.check_shared(sf_raw) assert t2.check_shared(sf_nb) + # Still need an explicit import for non-C++ type + t2.import_for_interop_explicit(tf.RawShared) + assert t2.check_shared(sf_raw) # Test normally passing these various objects for mod in (t2, tf): From 6fe43c923f32929d68852c1a03507526e46361c8 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Mon, 15 Sep 2025 23:15:15 -0600 Subject: [PATCH 8/8] Update to pymetabind 0.3 --- include/nanobind/nb_lib.h | 8 +- src/nb_foreign.cpp | 48 +++++++---- src/nb_type.cpp | 97 ++++++++++++++-------- src/pymetabind.h | 123 +++++++++++++++++++--------- tests/test_inter_module_foreign.cpp | 15 ++-- 5 files changed, 191 insertions(+), 100 deletions(-) diff --git a/include/nanobind/nb_lib.h b/include/nanobind/nb_lib.h index 856475580..3a975de7c 100644 --- a/include/nanobind/nb_lib.h +++ b/include/nanobind/nb_lib.h @@ -286,7 +286,13 @@ NB_CORE PyObject *nb_type_new(const type_init_data *c) noexcept; NB_CORE bool nb_type_get(const std::type_info *t, PyObject *o, uint8_t flags, cleanup_list *cleanup, void **out) noexcept; -/// Cast a C++ type instance into a Python object +/// Cast a C++ type instance into a Python object. +/// +/// Note: If you call this as if casting a shared_ptr (non-null `is_new`, true +/// `allow_foreign`, and `rv_policy::reference`) and you obtain a result with +/// `*is_new == true`, you must call `keep_alive` on the new instance after +/// you get it back. This allows for better interoperability with other +/// frameworks that store a smart pointer inside the instance. NB_CORE PyObject *nb_type_put(const std::type_info *cpp_type, void *value, rv_policy rvp, cleanup_list *cleanup, bool *is_new = nullptr, diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp index addf873a6..63f4481f3 100644 --- a/src/nb_foreign.cpp +++ b/src/nb_foreign.cpp @@ -80,8 +80,10 @@ static void *nb_foreign_from_python(pymb_binding *binding, static PyObject *nb_foreign_to_python(pymb_binding *binding, void *cobj, enum pymb_rv_policy rvp_, - PyObject *parent) noexcept { - cleanup_list cleanup{parent}; + pymb_to_python_feedback *feedback) noexcept { + feedback->relocate = 0; // we don't support relocation + feedback->is_new = 0; // unless overridden below + auto *td = (type_data *) binding->context; if (td->align == 0) { // enum int64_t key; @@ -102,15 +104,25 @@ static PyObject *nb_foreign_to_python(pymb_binding *binding, return enum_from_cpp(td->type, key, td->size); } - rv_policy rvp = (rv_policy) rvp_; - if (rvp < rv_policy::take_ownership || rvp > rv_policy::none) { - // Future-proofing in case additional pymb_rv_policies are defined - // later: if we don't recognize this policy, then refuse the cast - // unless a pyobject wrapper already exists. - rvp = rv_policy::none; + rv_policy rvp = rv_policy::none; // conservative default if rvp unrecognized + switch (rvp_) { + case pymb_rv_policy_take_ownership: + case pymb_rv_policy_copy: + case pymb_rv_policy_move: + case pymb_rv_policy_reference: + case pymb_rv_policy_none: + // These have the same values and semantics as our own policies + rvp = (rv_policy) rvp_; + break; + case pymb_rv_policy_share_ownership: + // Shared ownership is always held by keep_alive in nanobind, + // so there's nothing special to do here. + rvp = rv_policy::reference; + break; } - return nb_type_put(td->type, cobj, rvp, &cleanup, - /* is_new */ nullptr, /* allow_foreign */ false); + return nb_type_put(td->type, cobj, rvp, /* cleanup */ nullptr, + /* is_new */ (bool *) &feedback->is_new, + /* allow_foreign */ false); } static int nb_foreign_keep_alive(PyObject *nurse, @@ -121,10 +133,10 @@ static int nb_foreign_keep_alive(PyObject *nurse, keep_alive(nurse, payload, (void (*)(void*) noexcept) cb); else keep_alive(nurse, (PyObject *) payload); - return 0; + return 1; } catch (const std::runtime_error& err) { - PyErr_SetString(PyExc_RuntimeError, err.what()); - return -1; + PyErr_WriteUnraisable(nurse); + return 0; } } @@ -388,14 +400,14 @@ void nb_type_import_impl(PyObject *pytype, const std::type_info *cpptype) { register_with_pymetabind(internals); pymb_framework* foreign_self = internals->foreign_self; pymb_binding* binding = pymb_get_binding(pytype); -#if defined(Py_LIMITED_API) - str name_py = steal(PyType_GetName((PyTypeObject *) pytype)); + str name_py = steal(nb_type_name(pytype)); const char *name = name_py.c_str(); -#else - const char *name = ((PyTypeObject *) pytype)->tp_name; -#endif if (!binding) raise("'%s' does not define a __pymetabind_binding__", name); + if (binding->pytype != (PyTypeObject *) pytype) + raise("The binding defined by '%s' is for a different type '%s', " + "likely a superclass; import that type instead", + name, steal(nb_type_name((PyObject *) binding->pytype)).c_str()); if (binding->framework == foreign_self) raise("'%s' is already bound by this nanobind domain", name); if (!cpptype) { diff --git a/src/nb_type.cpp b/src/nb_type.cpp index c7c1f87d0..238f4dc4f 100644 --- a/src/nb_type.cpp +++ b/src/nb_type.cpp @@ -1767,7 +1767,8 @@ void keep_alive(PyObject *nurse, PyObject *patient) { if (!patient || !nurse || nurse == Py_None || patient == Py_None) return; - if (nb_type_check((PyObject *) Py_TYPE(nurse))) { + PyObject *nurse_ty = (PyObject *) Py_TYPE(nurse); + if (nb_type_check(nurse_ty)) { #if defined(NB_FREE_THREADED) nb_shard &shard = internals->shard(inst_ptr((nb_inst *) nurse)); lock_shard guard(shard); @@ -1797,6 +1798,11 @@ void keep_alive(PyObject *nurse, PyObject *patient) { Py_INCREF(patient); ((nb_inst *) nurse)->clear_keep_alive = true; } else { +#if !defined(NB_DISABLE_INTEROP) + if (pymb_binding *binding = pymb_get_binding(nurse_ty); + binding && binding->framework->keep_alive(nurse, patient, nullptr)) + return; +#endif PyObject *callback = PyCFunction_New(&keep_alive_callback_def, patient); @@ -1804,15 +1810,6 @@ void keep_alive(PyObject *nurse, PyObject *patient) { if (!weakref) { Py_DECREF(callback); PyErr_Clear(); -#if !defined(NB_DISABLE_INTEROP) - if (pymb_binding *binding = pymb_get_binding(nurse)) { - // Try a foreign framework's keep_alive as a last resort - if (binding->framework->keep_alive(nurse, patient, - nullptr) == 0) - return; - raise_python_error(); - } -#endif raise("nanobind::detail::keep_alive(): could not create a weak " "reference! Likely, the 'nurse' argument you specified is not " "a weak-referenceable type!"); @@ -1830,7 +1827,8 @@ void keep_alive(PyObject *nurse, void *payload, void (*callback)(void *) noexcept) noexcept { check(nurse, "nanobind::detail::keep_alive(): 'nurse' is undefined!"); - if (nb_type_check((PyObject *) Py_TYPE(nurse))) { + PyObject *nurse_ty = (PyObject *) Py_TYPE(nurse); + if (nb_type_check(nurse_ty)) { #if defined(NB_FREE_THREADED) nb_shard &shard = internals->shard(inst_ptr((nb_inst *) nurse)); lock_shard guard(shard); @@ -1850,6 +1848,11 @@ void keep_alive(PyObject *nurse, void *payload, ((nb_inst *) nurse)->clear_keep_alive = true; } else { +#if !defined(NB_DISABLE_INTEROP) + if (pymb_binding *binding = pymb_get_binding(nurse_ty); + binding && binding->framework->keep_alive(nurse, payload, callback)) + return; +#endif PyObject *patient = capsule_new(payload, nullptr, callback); keep_alive(nurse, patient); Py_DECREF(patient); @@ -1957,36 +1960,64 @@ PyObject *nb_type_put_foreign(nb_internals *internals_, bool *is_new) noexcept { struct capture { void *value; - rv_policy rvp; - PyObject *parent; - bool check_new; - bool is_new = false; - } cap{value, rvp, cleanup ? cleanup->self() : nullptr, bool(is_new)}; + pymb_rv_policy rvp = pymb_rv_policy_none; // conservative default + struct pymb_to_python_feedback feedback{}; + struct pymb_framework *used_framework = nullptr; + } cap{value}; + + switch (rvp) { + case rv_policy::reference_internal: + if (!cleanup || !cleanup->self()) + return nullptr; + cap.rvp = pymb_rv_policy_share_ownership; + break; + case rv_policy::reference: + if (is_new) { + cap.rvp = pymb_rv_policy_share_ownership; + break; + } + [[fallthrough]]; + case rv_policy::take_ownership: + case rv_policy::copy: + case rv_policy::move: + case rv_policy::none: + cap.rvp = (pymb_rv_policy) rvp; + break; + case rv_policy::automatic: + case rv_policy::automatic_reference: + check(false, + "nb_type_put_foreign(): automatic rvp should have been " + "converted to a different one before reaching here!"); + break; + } auto attempt = +[](void *closure, pymb_binding *binding) -> void* { capture &cap = *(capture *) closure; - if (cap.check_new || cap.rvp == rv_policy::none) { - PyObject* existing = binding->framework->to_python( - binding, cap.value, pymb_rv_policy_none, nullptr); - if (existing || cap.rvp == rv_policy::none) { - cap.is_new = false; - return existing; - } - cap.is_new = true; - } + cap.used_framework = binding->framework; return binding->framework->to_python( - binding, cap.value, (pymb_rv_policy) (uint8_t) cap.rvp, - cap.parent); + binding, cap.value, cap.rvp, &cap.feedback); }; - void *result = nullptr; + void *result_v = nullptr; if (cpp_type_p && cpp_type_p != cpp_type) - result = nb_type_try_foreign(internals_, cpp_type_p, attempt, &cap); - if (!result) - result = nb_type_try_foreign(internals_, cpp_type, attempt, &cap); + result_v = nb_type_try_foreign(internals_, cpp_type_p, attempt, &cap); + if (!result_v) + result_v = nb_type_try_foreign(internals_, cpp_type, attempt, &cap); + + PyObject *result = (PyObject *) result_v; if (is_new) - *is_new = cap.is_new; - return (PyObject *) result; + *is_new = cap.feedback.is_new; + if (result && rvp == rv_policy::reference_internal && cap.feedback.is_new && + !cap.used_framework->keep_alive(result, cleanup->self(), nullptr)) { + try { + keep_alive(result, cleanup->self()); + } catch (python_error& exc) { + exc.restore(); + Py_DECREF(result); + return nullptr; + } + } + return result; } #endif diff --git a/src/pymetabind.h b/src/pymetabind.h index a4a7cca43..1b7c52efe 100644 --- a/src/pymetabind.h +++ b/src/pymetabind.h @@ -6,15 +6,20 @@ * This functionality is intended to be used by the framework itself, * rather than by users of the framework. * - * This is version 0.2+dev of pymetabind. Changelog: + * This is version 0.3 of pymetabind. Changelog: * - * Unreleased: Don't do a Py_DECREF in `pymb_remove_framework` since the - * interpreter might already be finalized at that point. + * Version 0.3: Don't do a Py_DECREF in `pymb_remove_framework` since the + * 2025-09-15 interpreter might already be finalized at that point. * Revamp binding lifetime logic. Add `remove_local_binding` * and `free_local_binding` callbacks. * Add `pymb_framework::registry` and use it to simplify * the signatures of `pymb_remove_framework`, * `pymb_add_binding`, and `pymb_remove_binding`. + * Update `to_python` protocol to be friendlier to + * pybind11 instances with shared/smart holders. + * Remove `pymb_rv_policy_reference_internal`; add + * `pymb_rv_policy_share_ownership`. Change `keep_alive` + * return value convention. * * Version 0.2: Use a bitmask for `pymb_framework::flags` and add leak_safe * 2025-09-11 flag. Change `translate_exception` to be non-throwing. @@ -98,33 +103,36 @@ extern "C" { #endif /* - * Approach used to cast a previously unknown C++ instance into a Python object. - * The values of these enumerators match those for `nanobind::rv_policy` and - * `pybind11::return_value_policy`. + * Approach used to cast a previously unknown native instance into a Python + * object. This is similar to `pybind11::return_value_policy` or + * `nanobind::rv_policy`; some different options are provided than those, + * but same-named enumerators have the same semantics and values. */ enum pymb_rv_policy { - // (Values 0 and 1 correspond to `automatic` and `automatic_reference`, - // which should become one of the other policies before reaching us) - - // Create a Python object that owns a pointer to heap-allocated storage - // and will destroy and deallocate it when the Python object is destroyed + // Create a Python object that wraps a pointer to a heap-allocated + // native instance and will destroy and deallocate it (in whatever way + // is most natural for the target language) when the Python object is + // destroyed pymb_rv_policy_take_ownership = 2, - // Create a Python object that owns a new C++ instance created via - // copy construction from the given one + // Create a Python object that owns a new native instance created by + // copying the given one pymb_rv_policy_copy = 3, - // Create a Python object that owns a new C++ instance created via - // move construction from the given one + // Create a Python object that owns a new native instance created by + // moving the given one pymb_rv_policy_move = 4, - // Create a Python object that wraps the given pointer to a C++ instance + // Create a Python object that wraps a pointer to a native instance // but will not destroy or deallocate it pymb_rv_policy_reference = 5, - // `reference`, plus arrange for the given `parent` python object to - // live at least as long as the new object that wraps the pointer - pymb_rv_policy_reference_internal = 6, + // Create a Python object that wraps a pointer to a native instance + // and will perform a custom action when the Python object is destroyed. + // The custom action is specified using the first call to keep_alive() + // after the object is created, and such a call must occur in order for + // the object to be considered fully initialized. + pymb_rv_policy_share_ownership = 6, // Don't create a new Python object; only try to look up an existing one // from the same framework @@ -272,6 +280,23 @@ enum pymb_framework_flags { pymb_framework_leak_safe = 0x0002, }; +/* Additional results from `pymb_framework::to_python` */ +struct pymb_to_python_feedback { + // Ignored on entry. On exit, set to 1 if the returned Python object + // was created by the `to_python` call, or zero if it already existed and + // was simply looked up. + uint8_t is_new; + + // On entry, indicates whether the caller can control whether the native + // instance `cobj` passed to `to_python` is destroyed after the conversion: + // set to 1 if a relocation is allowable or 0 if `cobj` must be destroyed + // after the call. (This is only relevant when using pymb_rv_policy_move.) + // On exit, set to 1 if destruction should be inhibited because `*cobj` + // was relocated into the new instance. Must be left as zero on exit if + // set to zero on entry. + uint8_t relocate; +}; + /* * Information about one framework that has registered itself with pymetabind. * "Framework" here refers to a set of bindings that are natively mutually @@ -346,8 +371,8 @@ struct pymb_framework { // The function pointers below allow other frameworks to interact with // bindings provided by this framework. They are constant after construction - // and must not throw C++ exceptions. Unless otherwise documented, - // they must not be NULL. + // and must not throw C++ exceptions. They must not be NULL; if a feature + // is not relevant to your use case, provide a stub that always fails. // Extract a C/C++/etc object from `pyobj`. The desired type is specified by // providing a `pymb_binding*` for some binding that belongs to this @@ -375,9 +400,12 @@ struct pymb_framework { // a copy of the object to which `from_python`'s return value points before // you drop the references. // - // On free-threaded builds, callers must ensure that the `binding` is not - // destroyed during a call to `from_python`. The requirements for this are - // subtle; see the full discussion in the comment for `struct pymb_binding`. + // On free-threaded builds, no direct synchronization is required to call + // this method, but you must ensure the `binding` won't be destroyed during + // (or before) your call. This generally requires maintaining a continuously + // attached Python thread state whenever you hold a pointer to `binding` + // that a concurrent call to your framework's `remove_foreign_binding` + // method wouldn't be able to clear. See the comment for `pymb_binding`. void* (*from_python)(struct pymb_binding* binding, PyObject* pyobj, uint8_t convert, @@ -386,30 +414,45 @@ struct pymb_framework { // Wrap the C/C++/etc object `cobj` into a Python object using the given // return value policy. The type is specified by providing a `pymb_binding*` - // for some binding that belongs to this framework. `parent` is relevant - // only if `rvp == pymb_rv_policy_reference_internal`. rvp must be one of - // the defined enumerators. Returns NULL if the cast is not possible, or - // a new reference otherwise. + // for some binding that belongs to this framework. + // + // The semantics of this function are as follows: + // - If there is already a live Python object created by this framework for + // this C++ object address and type, it will be returned and the `rvp` is + // ignored. + // - Otherwise, if `rvp == pymb_rv_policy_none`, NULL is returned without + // the Python error indicator set. + // - Otherwise, a new Python object will be created and returned. It will + // wrap either the pointer `cobj` or a copy/move of the contents of + // `cobj`, depending on the value of `rvp`. + // + // Returns a new reference to a Python object, or NULL if not possible. + // Also sets *feedback to provide additional information about the + // conversion. // - // A NULL return may leave the Python error indicator set if something - // specifically describable went wrong during conversion, but is not - // required to; returning NULL without PyErr_Occurred() should be - // interpreted as a generic failure to convert `cobj` to a Python object. + // After a successful `to_python` call that returns a new instance and + // used `pymb_rv_policy_share_ownership`, the caller must make a call to + // `keep_alive` to describe how the shared ownership should be managed. // - // On free-threaded builds, callers must ensure that the `binding` is not - // destroyed during a call to `to_python`. The requirements for this are - // subtle; see the full discussion in the comment for `struct pymb_binding`. + // On free-threaded builds, no direct synchronization is required to call + // this method, but you must ensure the `binding` won't be destroyed during + // (or before) your call. This generally requires maintaining a continuously + // attached Python thread state whenever you hold a pointer to `binding` + // that a concurrent call to your framework's `remove_foreign_binding` + // method wouldn't be able to clear. See the comment for `pymb_binding`. PyObject* (*to_python)(struct pymb_binding* binding, void* cobj, enum pymb_rv_policy rvp, - PyObject* parent) PYMB_NOEXCEPT; + struct pymb_to_python_feedback* feedback) PYMB_NOEXCEPT; // Request that a PyObject reference be dropped, or that a callback // be invoked, when `nurse` is destroyed. `nurse` should be an object // whose type is bound by this framework. If `cb` is NULL, then // `payload` is a PyObject* to decref; otherwise `payload` will - // be passed as the argument to `cb`. Returns 0 if successful, - // or -1 and sets the Python error indicator on error. + // be passed as the argument to `cb`. Returns 1 if successful, + // 0 on error. This method may always return 0 if the framework has + // no better way to do a keep-alive than by creating a weakref; + // it is expected that the caller can handle creating the weakref. // // No synchronization is required to call this method. int (*keep_alive)(PyObject* nurse, @@ -424,8 +467,8 @@ struct pymb_framework { // such as `std::exception`. If translation succeeds, return 1 with the // Python error indicator set; otherwise, return 0. An exception may be // converted into a different exception by modifying `*eptr` and returning - // zero. This function pointer may be NULL if this framework does not - // provide exception translation. + // zero. This method may be set to NULL if its framework does not have + // a concept of exception translation. // // No synchronization is required to call this method. int (*translate_exception)(void* eptr) PYMB_NOEXCEPT; diff --git a/tests/test_inter_module_foreign.cpp b/tests/test_inter_module_foreign.cpp index 7bb34bcb5..085a633dd 100644 --- a/tests/test_inter_module_foreign.cpp +++ b/tests/test_inter_module_foreign.cpp @@ -53,7 +53,7 @@ static PyObject *Shared_new(PyTypeObject *type, Shared *value, pymb_rv_policy rv self->deallocate = false; break; case pymb_rv_policy_reference: - case pymb_rv_policy_reference_internal: + case pymb_rv_policy_share_ownership: self->ptr = value; self->deallocate = false; break; @@ -86,17 +86,14 @@ static void *hook_from_python(pymb_binding *binding, static PyObject *hook_to_python(pymb_binding *binding, void *cobj, enum pymb_rv_policy rvp, - PyObject *) noexcept { + pymb_to_python_feedback *feedback) noexcept { + feedback->relocate = 0; if (rvp == pymb_rv_policy_none) return nullptr; + feedback->is_new = 1; return Shared_new(binding->pytype, (Shared *) cobj, rvp); } -static int hook_keep_alive(PyObject *, void *, void (*)(void*)) noexcept { - PyErr_SetString(PyExc_RuntimeError, "keep_alive not supported"); - return -1; -} - static void hook_ignore_foreign_binding(pymb_binding *) noexcept {} static void hook_ignore_foreign_framework(pymb_framework *) noexcept {} @@ -131,7 +128,9 @@ NB_MODULE(test_inter_module_foreign_ext, m) { fw->abi_lang = pymb_abi_lang_c; fw->from_python = hook_from_python; fw->to_python = hook_to_python; - fw->keep_alive = hook_keep_alive; + fw->keep_alive = [](PyObject *, void *, void (*)(void *)) noexcept { + return 0; + }; fw->remove_local_binding = [](pymb_binding *) noexcept {}; fw->free_local_binding = [](pymb_binding *binding) noexcept { delete binding;