Skip to content

vptr_vector: rebuild the vector, do not resize it in place - #104

Open
jll63 wants to merge 3 commits into
boostorg:developfrom
jll63:fix/vptr-vector-stale-slots
Open

vptr_vector: rebuild the vector, do not resize it in place#104
jll63 wants to merge 3 commits into
boostorg:developfrom
jll63:fix/vptr-vector-stale-slots

Conversation

@jll63

@jll63 jll63 commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

(Written by Claude Code, on behalf of @jll63.)

vptr_vector::initialize sized its vector with resize(size) and then filled entries in
place. resize keeps the elements that fit, so an index belonging to a class that is no
longer registered kept the v-table pointer written by a previous call — a pointer into the
dispatch data the commit-time swap has just freed.

Classes stop being registered when a shared library is unloaded: registrars add themselves
in static constructors and remove themselves in the matching destructors. So dlopen
initialize()dlcloseinitialize(), the flow shared_libraries.adoc documents,
leaves one stale slot per class the library contributed.

Reproduced deterministically (small integer type ids, no type_hash, so index == type id):

with Tiger   : vptrs.size=5 [1]=0x...fa0 [2]=0x...fa8 [3]=0x...fb8 [4]=0x...fb0
Tiger dropped: vptrs.size=5 [1]=0x...7a0 [2]=0x...7a8 [3]=0x...fb8 [4]=0x...7b0
                                                        ^^ never rewritten
ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 ... in ...::resolve_uni<...> core.hpp:2479
freed by ... write_global_data() initialize.hpp:1930

The change

Build a new vector and swap it in, the way vptr_map::initialize already does. Every slot
the fill loop does not write is null rather than whatever the previous run left there. As a
side effect the live vector is untouched until the swap, so the policy no longer depends on
the initialize() transaction to undo a half-written fill.

vptr_vector was the only registry-persistent container in include/ filled by in-place
indexed assignment; every other resize is on a per-run compiler object or is followed by a
full std::fill.

Scope

Reading one of these slots requires dispatching on a class that is not currently
registered
, which is an erroneous call to begin with — every registered class gets its slot
written. This is a robustness fix, not a case of correct code breaking. What it buys is that
the erroneous call now fails immediately and locally: the heap-use-after-free above becomes a
null read at the same site.

Not fixed here. Under runtime_checks the cleared slot is still in bounds, so vptr()
null-dereferences instead of reporting missing_class the way vptr_map does on a miss —
which is what the doc comment on vptr() promises. Closing that needs a null check plus a
guarantee that a registered class never holds a null v-table pointer, which does not hold
today: a registry with classes and no methods has empty dispatch data and every class in it
gets a null vptr. Left for a separate change.

Test

test/test_initialize_dropped_class.cpp drops a class by letting a value-initialised,
block-scoped use_classes die, which runs the same registrar destructor an unload runs. It
covers the direct and indirect vector configurations and carries vptr_map as a control:
before this commit the two vector cases fail and the map case passes.

Two things the test comments record, because both cost time to find:

  • an overrider registers through override_aux::impl, a static member whose
    instantiation the registrar object merely forces, so scoping an overrider does nothing —
    the dropped class must not have one;
  • static_link() = default leaves the list links uninitialised, and every registrar the
    macros emit lives in static storage where they are zeroed for free. An automatic
    use_classes has to be value-initialised or push_back asserts on the garbage.

Verification

  • CMake/Ninja, gcc, Debug: 161/161 ctest pass.
  • b2 toolset=gcc: 548 targets updated, no failures, including both dynamic_loading variants.
  • Test confirmed red before the fix and green after.
  • clang-format-22 applied.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JQa4fuiwcfsheZYTCyfPPr

`initialize` sized the vector with `resize(size)` and then filled entries in
place. `resize` keeps the elements that fit, so an index belonging to a class
that is no longer registered kept the v-table pointer written by a previous
call - a pointer into the dispatch data the commit-time swap has just freed.

Classes stop being registered when a shared library is unloaded: the
registrars add themselves in static constructors and remove themselves in the
matching destructors, so `dlopen` -> initialize -> `dlclose` -> initialize,
the flow shared_libraries.adoc documents, leaves one stale slot per class the
library contributed. Dispatching on such a class then reads freed memory,
and, after a `dlclose`, jumps into unloaded code.

Build a new vector and swap it in, the way `vptr_map::initialize` already
does. Every slot the fill loop does not write is null rather than whatever the
previous run left there. As a side effect the live vector is untouched until
the swap, so the policy no longer depends on the initialize() transaction to
undo a half-written fill.

Scope, stated plainly: reading one of these slots requires dispatching on a
class that is not currently registered, which is an erroneous call to begin
with - every registered class gets its slot written. So this is a robustness
fix, not a case of correct code breaking. What it buys is that the erroneous
call now fails immediately and locally: ASan turns a heap-use-after-free in
resolve_uni into a null read at the same site.

What it does not fix: under `runtime_checks` the cleared slot is still in
bounds, so `vptr()` null-dereferences instead of reporting `missing_class` the
way `vptr_map` does on a miss - which is what the doc comment on `vptr()`
promises. Closing that needs a null check plus a guarantee that a registered
class never holds a null v-table pointer, which does not hold today: a
registry with classes and no methods has empty dispatch data, and every class
in it gets a null vptr. Left for a separate change.

The test drops a class by letting a value-initialised, block-scoped
`use_classes` die, which runs the same registrar destructor an unload runs. It
covers the direct and indirect vector configurations, and carries `vptr_map`
as a control: before this commit the two vector cases fail and the map case
passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQa4fuiwcfsheZYTCyfPPr
@cppalliance-bot

cppalliance-bot commented Sep 12, 2026

Copy link
Copy Markdown

An automated preview of the documentation is available at https://104.openmethod.prtest3.cppalliance.org/libs/openmethod/doc/html/index.html

If more commits are pushed to the pull request, the docs will rebuild at the same URL.

2026-09-12 13:37:04 UTC

jll63 and others added 2 commits September 12, 2026 09:22
The comment explained that a value-initialised registrar is needed because
static storage "is zeroed for free", which reads as a happy accident and
invites someone to give the links initializers instead. It is the opposite:
the links deliberately have no dynamic initializer so that registration does
not depend on static initialization order. Registrars live in static storage,
zeroed before any dynamic initialization, so one can link itself in whatever
order the translation units' constructors run - and no list head can be
constructed after it and wipe the registrations. Adding an initializer would
reintroduce the static initialization order fiasco.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQa4fuiwcfsheZYTCyfPPr
A registrar is documented to be a static object - core.hpp says so on
`override`, macros.hpp on BOOST_OPENMETHOD_REGISTER, shared_libraries.adoc on
the registrars generally. This test puts one on the stack because a static
never dies before the program does, and the test needs the registration to go
away between two initialize() calls, which is what unloading a library does.
Say so, so the spelling is not lifted into an example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQa4fuiwcfsheZYTCyfPPr
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.41%. Comparing base (5ec487c) to head (b382b02).
⚠️ Report is 10 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop     #104      +/-   ##
===========================================
- Coverage    93.46%   93.41%   -0.06%     
===========================================
  Files           22       22              
  Lines         1653     1686      +33     
  Branches       500      507       +7     
===========================================
+ Hits          1545     1575      +30     
- Misses          64       66       +2     
- Partials        44       45       +1     
Files with missing lines Coverage Δ
include/boost/openmethod/policies/vptr_vector.hpp 100.00% <100.00%> (ø)

... and 3 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update ba56242...b382b02. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants