Skip to content

initialize: move the last throwing statement before the commit - #101

Open
jll63 wants to merge 2 commits into
boostorg:developfrom
jll63:fix/initialize-commit-order
Open

initialize: move the last throwing statement before the commit#101
jll63 wants to merge 2 commits into
boostorg:developfrom
jll63:fix/initialize-commit-order

Conversation

@jll63

@jll63 jll63 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

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

Follow-up to #95.

transaction.commit() was called one statement too early: the ++tr << "Installing\n" that follows it goes through the output policy, which is user code and can throw. When it does, the transaction destructor sees a committed transaction and restores nothing, so the policies keep the v-table pointers they read out of new_dispatch_data — the local vector unwinding is about to free. The dispatch data itself is untouched (the swap never ran), so the next dispatch through the policy state reads freed memory: the use-after-free #95 fixed, reached by a different route.

Reproduced with ASan, gcc 13, an output policy whose stream throws on "Installing", and initialize(trace(true)):

dispatch_data UNCHANGED (before=0x…00a0 after=0x…00a0)
ERROR: AddressSanitizer: heap-use-after-free
  READ of size 8 ... in resolve_uni<...>
  freed by ... write_global_data()

The trace write moves above commit(), where a throw rolls the policies back, and the writes that follow are extracted into commit_global_data(), declared noexcept so a throwing statement added there terminates loudly instead of silently reopening this.

Tests

test_initialize_transaction gained a case for the throwing-trace path, and two of its existing assertions turned out to be vacuous: the failing initialize saw exactly the input the preceding successful one saw, so fast_perfect_hash — which re-seeds a fixed PRNG — recomputed identical factors, and next<poke_dog> resolved to the same overrider. Both held whether or not anything was rolled back; reintroducing the dangling-next half of #81 left the suite reporting No errors detected.

The two calls now see different inputs, via two function-local static registrars: an extra class changes the hash factors, the control table and the v-table pointers, and an extra inheritance edge changes what next<poke_dog> would be set to. The hash assertion compares the whole policy state — factors and control table — rather than hash_range() alone.

Verified by sabotage: disabling the rollback fails 12 assertions (it failed 7 before, none of them the hash), moving the next write back before the transaction fails 2, and the new case fails on the original commit ordering.

Suites: Debug/gcc 13, 160/160; Debug/g++-16 with reflection and shared libraries, 166/166.

Not addressed here

install_global_tables() still runs print(report), print_slots() and "Finished\n" after the commit point and before st.initialized = true. A throw there — the same user-supplied output policy, or a bad_alloc from the containers print_slots builds — leaves the new state fully installed while the registry reads as uninitialized, which contradicts the exception-safety paragraph on initialize. The remedy is a contract decision (move the reporting before the commit, or narrow the documented guarantee), so it is left for a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RQG6CbE4o2agseE7bDVzHS

boostorg#95 made initialize() transactional, but `transaction.commit()` was called
one statement too early: the `++tr << "Installing\n"` that follows it goes
through the `output` policy, which is user code and can throw. When it does,
the transaction destructor sees a committed transaction and restores nothing,
so the policies keep the v-table pointers they read out of `new_dispatch_data`
- the local vector unwinding is about to free. The registry still holds the
previous dispatch data (the swap never ran), so the next dispatch through the
policy state reads freed memory: the exact use-after-free boostorg#95 fixed, reached
by a different route. ASan, gcc 13, an `output` policy whose stream throws on
"Installing" plus initialize(trace(true)):

    ERROR: AddressSanitizer: heap-use-after-free
      READ of size 8 ... in resolve_uni<...>
      freed by ... write_global_data()

Move the trace write above commit(), where a throw rolls the policies back,
and extract the writes that follow into `commit_global_data()`, declared
`noexcept` so that a throwing statement added there terminates loudly instead
of silently reopening this.

The test grew a case for that path, and two of its existing assertions were
vacuous: the failing initialize saw exactly the input the preceding successful
one saw, so `fast_perfect_hash` - which re-seeds a fixed PRNG - recomputed
identical factors, and `next<poke_dog>` resolved to the same overrider. Both
held whether or not anything was rolled back; reintroducing the dangling-`next`
half of boostorg#81 left the suite reporting "No errors detected". Perturb the input
between the two calls instead, with two function-local static registrars: an
extra class changes the hash factors, the control table and the v-table
pointers, and an extra inheritance edge changes what `next<poke_dog>` would be
set to. The hash assertion also compares the whole policy state now - factors
and control table - rather than `hash_range()` alone.

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

cppalliance-bot commented Sep 11, 2026

Copy link
Copy Markdown

An automated preview of the documentation is available at https://101.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 00:17:10 UTC

The transaction section says the registry is marked as not initialized and
that initialize() must be called again, but not what happens if a method is
called before it is. Only `runtime_checks` diagnoses that; otherwise the call
dispatches through the previous tables, which is harmless for a registry whose
classes are all still loaded and a use-after-free for one whose overriders
came out of a library that has since been dlclose'd - the case the section is
written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQa4fuiwcfsheZYTCyfPPr
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