Skip to content

Commit e36686c

Browse files
committed
Fix dangling pointer in internals::registered_types_cpp_fast from pybind#5842
@oremanj pointed out in a comment on pybind#5842 that I missed part of the nanobind PR I was porting in such a way that we could have dangling pointers in internals::registered_types_cpp_fast. This PR adds a test that reproed the bug and then fixes the test.
1 parent 9f75202 commit e36686c

File tree

7 files changed

+130
-2
lines changed

7 files changed

+130
-2
lines changed

include/pybind11/detail/class.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ extern "C" inline void pybind11_meta_dealloc(PyObject *obj) {
228228
internals.registered_types_cpp.erase(tindex);
229229
#if PYBIND11_INTERNALS_VERSION >= 12
230230
internals.registered_types_cpp_fast.erase(tinfo->cpptype);
231+
const alias_chain_entry *chain = tinfo->alias_chain.get();
232+
while (chain) {
233+
auto num_erased = internals.registered_types_cpp_fast.erase(chain->value);
234+
(void) num_erased;
235+
assert(num_erased > 0);
236+
chain = chain->next.get();
237+
}
231238
#endif
232239
}
233240
internals.registered_types_py.erase(tinfo->type);

include/pybind11/detail/internals.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,22 @@ enum class holder_enum_t : uint8_t {
335335
custom_holder,
336336
};
337337

338+
// When a type appears in multiple DSOs,
339+
// internals::registered_types_cpp_fast will have multiple distinct
340+
// keys (the type_info from each DSO) mapped to the same
341+
// type_info*. We need to keep track of these aliases so that we clean
342+
// them up when our type is deallocated. A linked list is appropriate
343+
// because this structure is expected to be 1) usually empty and 2)
344+
// when it's not empty, usually very small. See also `struct
345+
// nb_alias_chain` added in
346+
// https://github.com/wjakob/nanobind/commit/b515b1f7f2f4ecc0357818e6201c94a9f4cbfdc2
347+
#if PYBIND11_INTERNALS_VERSION >= 12
348+
struct alias_chain_entry {
349+
std::unique_ptr<alias_chain_entry> next;
350+
const std::type_info *value;
351+
};
352+
#endif
353+
338354
/// Additional type information which does not fit into the PyTypeObject.
339355
/// Changes to this struct also require bumping `PYBIND11_INTERNALS_VERSION`.
340356
struct type_info {
@@ -357,6 +373,11 @@ struct type_info {
357373
void *get_buffer_data = nullptr;
358374
void *(*module_local_load)(PyObject *, const type_info *) = nullptr;
359375
holder_enum_t holder_enum_v = holder_enum_t::undefined;
376+
377+
#if PYBIND11_INTERNALS_VERSION >= 12
378+
std::unique_ptr<alias_chain_entry> alias_chain;
379+
#endif
380+
360381
/* A simple type never occurs as a (direct or indirect) parent
361382
* of a class that makes use of multiple inheritance.
362383
* A type can be simple even if it has non-simple ancestors as long as it has no descendants.

include/pybind11/detail/type_caster_base.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,14 @@ inline detail::type_info *get_global_type_info_lock_held(const std::type_info &t
246246
auto it = types.find(std::type_index(tp));
247247
if (it != types.end()) {
248248
#if PYBIND11_INTERNALS_VERSION >= 12
249+
// We found the type in the slow map but not the fast one, so
250+
// some other DSO added it (otherwise it would be in the fast
251+
// map under &tp) and therefore we must be an alias. Record
252+
// that in the chain.
253+
std::unique_ptr<alias_chain_entry> chain(new alias_chain_entry);
254+
chain->value = &tp;
255+
chain->next = std::move(it->second->alias_chain);
256+
it->second->alias_chain = std::move(chain);
249257
fast_types.emplace(&tp, it->second);
250258
#endif
251259
type_info = it->second;

tests/CMakeLists.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ set(PYBIND11_TEST_FILES
117117
test_callbacks
118118
test_chrono
119119
test_class
120+
test_class_cross_module_use_after_one_module_dealloc
120121
test_class_release_gil_before_calling_cpp_dtor
121122
test_class_sh_basic
122123
test_class_sh_disowning
@@ -239,8 +240,9 @@ list(SORT PYBIND11_PYTEST_FILES)
239240
# Contains the set of test files that require pybind11_cross_module_tests to be
240241
# built; if none of these are built (i.e. because TEST_OVERRIDE is used and
241242
# doesn't include them) the second module doesn't get built.
242-
tests_extra_targets("test_exceptions.py;test_local_bindings.py;test_stl.py;test_stl_binders.py"
243-
"pybind11_cross_module_tests")
243+
tests_extra_targets(
244+
"test_class_cross_module_use_after_one_module_dealloc.py;test_exceptions.py;test_local_bindings.py;test_stl.py;test_stl_binders.py"
245+
"pybind11_cross_module_tests")
244246

245247
# And add additional targets for other tests.
246248
tests_extra_targets("test_exceptions.py" "cross_module_interleaved_error_already_set")

tests/pybind11_cross_module_tests.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
#include <numeric>
1717
#include <utility>
1818

19+
class CrossDSOClass {
20+
public:
21+
virtual ~CrossDSOClass();
22+
};
23+
24+
CrossDSOClass::~CrossDSOClass() = default;
25+
1926
PYBIND11_MODULE(pybind11_cross_module_tests, m, py::mod_gil_not_used()) {
2027
m.doc() = "pybind11 cross-module test module";
2128

@@ -146,4 +153,7 @@ PYBIND11_MODULE(pybind11_cross_module_tests, m, py::mod_gil_not_used()) {
146153
// which appears when this header is missing.
147154
m.def("missing_header_arg", [](const std::vector<float> &) {});
148155
m.def("missing_header_return", []() { return std::vector<float>(); });
156+
157+
// test_class_cross_module_use_after_one_module_dealloc
158+
m.def("consume_cross_dso_class", [](CrossDSOClass) {});
149159
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#include "pybind11_tests.h"
2+
3+
#include <iostream>
4+
5+
class CrossDSOClass {
6+
public:
7+
virtual ~CrossDSOClass();
8+
};
9+
10+
CrossDSOClass::~CrossDSOClass() = default;
11+
12+
struct UnrelatedClass {};
13+
14+
TEST_SUBMODULE(class_cross_module_use_after_one_module_dealloc, m) {
15+
m.def("register_and_instantiate_cross_dso_class", [](py::module_ m) {
16+
py::class_<CrossDSOClass>(m, "CrossDSOClass").def(py::init<>());
17+
return CrossDSOClass();
18+
});
19+
m.def("register_unrelated_class",
20+
[](py::module_ m) { py::class_<UnrelatedClass>(m, "UnrelatedClass"); });
21+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
import gc
4+
import types
5+
import weakref
6+
7+
import pytest
8+
9+
import env # noqa: F401
10+
from pybind11_tests import class_cross_module_use_after_one_module_dealloc as m
11+
12+
13+
def delattr_and_ensure_destroyed(*specs):
14+
wrs = []
15+
for mod, name in specs:
16+
wrs.append(weakref.ref(getattr(mod, name)))
17+
delattr(mod, name)
18+
19+
for _ in range(5):
20+
gc.collect()
21+
if all(wr() is None for wr in wrs):
22+
break
23+
else:
24+
pytest.fail(
25+
f"Could not delete bindings such as {next(wr for wr in wrs if wr() is not None)!r}"
26+
)
27+
28+
29+
@pytest.mark.skipif("env.PYPY or env.GRAALPY")
30+
def test_cross_module_use_after_one_module_dealloc():
31+
# This is a regression test for a bug that occurred during development of
32+
# internals::registered_types_cpp_fast (see #5842). registered_types_cpp_fast maps
33+
# &typeid(T) to a raw non-owning pointer to a Python metaclass. If two DSOs both
34+
# look up the same global type, they will create two separate entries in
35+
# registered_types_cpp_fast, which will look like:
36+
# +=======================================+
37+
# |&typeid(T) from DSO 1|metaclass pointer|
38+
# |&typeid(T) from DSO 2|metaclass pointer|
39+
# +=======================================+
40+
#
41+
# Then, if the metaclass is destroyed and we don't take extra steps to clean up the
42+
# table thoroughly, the first row of the table will be cleaned up but the second one
43+
# will contain a dangling pointer to the old metaclass instance. Further lookups
44+
# from DSO 2 will then return that dangling pointer, which will cause use-after-frees.
45+
46+
import pybind11_cross_module_tests as cm
47+
48+
module_scope = types.ModuleType("module_scope")
49+
instance = m.register_and_instantiate_cross_dso_class(module_scope)
50+
cm.consume_cross_dso_class(instance)
51+
52+
del instance
53+
delattr_and_ensure_destroyed((module_scope, "CrossDSOClass"))
54+
55+
# Make sure that CrossDSOClass gets allocated at a different address.
56+
m.register_unrelated_class(module_scope)
57+
58+
instance = m.register_and_instantiate_cross_dso_class(module_scope)
59+
cm.consume_cross_dso_class(instance)

0 commit comments

Comments
 (0)