Repository navigation
Fix data race on AbstractMetaObjectBase's owning-loader list - #236
thomasmoore16 wants to merge 2 commits into
Conversation
|
For reference, tracking the two related PRs mentioned above, both already merged:
This PR fixes a third, still-unaddressed race in the same area (AbstractMetaObjectBase's owning-loader list). |
AbstractMetaObjectBase::addOwningClassLoader()/removeOwningClassLoader()/ isOwnedBy() (and the other accessors of associated_class_loaders_) took no lock of their own. createInstance() in class_loader_core.hpp released getPluginBaseToFactoryMapMapMutex() before reading factory->isOwnedBy(loader), so a concurrent addOwningClassLoader()/removeOwningClassLoader() on another thread (e.g. from loadLibrary()'s already-loaded branch, or from onPluginDeletion()) could race with that read - and, since it's a std::vector, with any other concurrent read/write of the same metaobject's owner list. Reported by ThreadSanitizer as a data race on std::vector<ClassLoader *>'s push_back()/erase() (via vector's internal reallocation), hit by constructing many independent ClassLoader instances for the same library concurrently - e.g. one rosbag2_cpp::Writer per thread, each internally constructing its own pluginlib::ClassLoader for the serialization format converter. Give AbstractMetaObjectBaseImpl its own recursive_mutex guarding associated_class_loaders_, so every accessor is self-contained and safe regardless of what lock (if any) the caller happens to be holding. Also extend createInstance()'s existing getPluginBaseToFactoryMapMapMutex() scope to cover the isOwnedBy() checks themselves, since between releasing that mutex and reading isOwnedBy(), the factory pointer looked up under it could in principle be invalidated by a concurrent destroyMetaObjectsForLibrary() on another thread. This is separate from ros#234 (loadLibrary()/unloadLibrary() overlap) and ros#231 (has_unmanaged_instance_been_created_): those fixed different unsynchronized globals in this same area. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: thomasmoore-torc <thomas.moore@torc.ai>
The regression test constructed and destroyed STRESS_TEST_NUM_THREADS (500) independent ClassLoaders for the same library with no synchronization between their lifetimes, so the library's refcount could repeatedly bounce to zero under contention, forcing many real dlopen()/dlclose() cycles - and occasionally racing two of those "last owner out" unloads against each other, an existing, separate bug in this library's load/unload refcounting unrelated to the fix under test here. Add an `anchor` ClassLoader kept alive for the whole test so the refcount never reaches zero and that unrelated path is never exercised, isolating the associated_class_loaders_ race this fix actually targets. Verified in a local colcon/ROS 2 rolling Docker environment with ThreadSanitizer: unpatched, this reliably reports the addOwningClassLoader()/removeOwningClassLoader() race (9-10 reports per run); with the fix, zero reports across many runs, and the full `colcon test` suite for class_loader passes 100% (9/9). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: thomasmoore-torc <thomas.moore@torc.ai>
80ed16e to
18bc709
Compare
mjcarroll
left a comment
There was a problem hiding this comment.
Thanks for tracking this down. I have some feedback here.
| // isOwnedBy() must be read while still holding this mutex: another thread's | ||
| // addOwningClassLoader()/removeOwningClassLoader() (e.g. from loadLibrary()'s | ||
| // already-loaded branch) is only serialized against this same mutex. |
There was a problem hiding this comment.
This comment says add/remove are "only serialized against this same mutex", but this PR also adds a per-object mutex in meta_object.cpp, which contradicts it.
| Base * obj = nullptr; | ||
| if (factory != nullptr && factory->isOwnedBy(loader)) { | ||
| if (factory != nullptr && owned_by_loader) { | ||
| obj = factory->create(std::forward<Args>(args)...); |
There was a problem hiding this comment.
Reading isOwnedBy() under the lock is an improvement, but this doesn't close the use-after-free described in the PR. factory->create() still runs after the map mutex is released. If another thread drops the last owner in that window, destroyMetaObjectsForLibrary moves the metaobject to the graveyard and the library can be dlclose'd. We'd then call create() on a freed metaobject. owned_by_loader is also out of date by this point.
Could we keep the lock held through create()?
| { | ||
| public: | ||
| // Guards associated_class_loaders_: class_loader_core.hpp's createInstance() reads | ||
| // isOwnedBy() after releasing getPluginBaseToFactoryMapMapMutex(), so this vector needs |
There was a problem hiding this comment.
After this PR, createInstance() no longer reads isOwnedBy() after releasing etPluginBaseToFactoryMapMapMutex(). This comment describes the old code?
| // Guards associated_class_loaders_: class_loader_core.hpp's createInstance() reads | ||
| // isOwnedBy() after releasing getPluginBaseToFactoryMapMapMutex(), so this vector needs | ||
| // its own lock independent of that outer mutex. | ||
| mutable std::recursive_mutex associated_class_loaders_mutex_; |
There was a problem hiding this comment.
Every writer of associated_class_loaders_ already holds getPluginBaseToFactoryMapMapMutex(), the actual bug is readers that run outside of that mutex. The createInstacne change already fixes the one in this PR. I'd rather drop the per-object mutex and have the remaining readers use the map mutex.
I think that a regular mutex is also sufficient, since nothing should re-enter the lock?
|
|
||
| ClassLoader * AbstractMetaObjectBase::getAssociatedClassLoader(size_t index) const | ||
| { | ||
| std::lock_guard<std::recursive_mutex> lock(impl_->associated_class_loaders_mutex_); |
There was a problem hiding this comment.
This doesn't necessarily make it safe.
printDebugInfoToScreen calls getAssociatedClassLoadersCount() and then loops over getAssociatedClassLoader(i). A concurrent remove would make index read past the end.
| [&failures]() { | ||
| try { | ||
| class_loader::ClassLoader loader(LIBRARY_1); | ||
| std::vector<std::string> classes = loader.getAvailableClasses<Base>(); |
There was a problem hiding this comment.
I think that we want to assert that classes isn't empty so that we know we are actually testing something?
| loader.createInstance<Base>(class_name)->saySomething(); | ||
| } | ||
| } catch (...) { | ||
| ++failures; |
There was a problem hiding this comment.
Can we actually catch the exception and print it here so that we can get something if there is a failure?
Problem
AbstractMetaObjectBase::addOwningClassLoader()/removeOwningClassLoader()/isOwnedBy()(and the other accessors ofassociated_class_loaders_) take no lock of their own.createInstance()inclass_loader_core.hppreleasesgetPluginBaseToFactoryMapMapMutex()before readingfactory->isOwnedBy(loader), so a concurrentaddOwningClassLoader()/removeOwningClassLoader()on another thread (e.g. fromloadLibrary()'s already-loaded branch) can race with that read — and, since it's astd::vector, with any other concurrent read/write of the same metaobject's owner list.I hit this with ThreadSanitizer as a data race on
std::vector<ClassLoader *>'spush_back()/erase()(via the vector's internal reallocation). It's straightforward to trigger by constructing many independentClassLoaderinstances for the same library concurrently — e.g. onerosbag2_cpp::Writerper thread, each internally constructing its ownpluginlib::ClassLoaderfor the serialization format converter, all for the first time, simultaneously.This is separate from #234 (
loadLibrary()/unloadLibrary()overlap) and #231 (has_unmanaged_instance_been_created_) — both already merged, and both fix different unsynchronized globals in this same area. I checked the currentrollingHEAD before writing this:createInstance()still releases the mutex before theisOwnedBy()checks, andmeta_object.cpp's accessors are still fully unguarded.Fix
AbstractMetaObjectBaseImplits ownrecursive_mutexguardingassociated_class_loaders_, so every accessor (addOwningClassLoader,removeOwningClassLoader,isOwnedBy,isOwnedByAnybody,getAssociatedClassLoadersCount,getAssociatedClassLoader) is self-contained and safe regardless of what lock, if any, the caller happens to be holding.createInstance()'s existinggetPluginBaseToFactoryMapMapMutex()scope to cover theisOwnedBy()checks themselves: between releasing that mutex (previously done right after the map lookup) and readingisOwnedBy(), thefactorypointer looked up under it could in principle be invalidated by a concurrentdestroyMetaObjectsForLibrary()on another thread. The mutex-per-vector fix above closes the vector-corruption race on its own; this closes the separate use-after-free risk on thefactorypointer itself.Testing
Added
ClassLoaderTest.threadSafetyMultipleLoadersPerLibrarytotest/utest.cpp. Unlike the existingClassLoaderTest.threadSafety(oneClassLoadershared by allSTRESS_TEST_NUM_THREADSthreads, soisOwnedBy()is always queried with the loader that already, and permanently, owns the metaobject), this constructs a separateClassLoaderper thread for the same library, so construction/destruction concurrently mutatesassociated_class_loaders_while other threads concurrently read it viaisOwnedBy()increateInstance(). AnanchorClassLoaderis kept alive for the whole test so the library's owner count never reaches zero and the worker threads never trigger the actual unload/dlclose()path — that path has its own pre-existing, unrelated refcounting bug (a library can bedlclose()-d twice when independentClassLoaders for the same library race to be "last owner out"), which isn't what this test is targeting and would otherwise fail it intermittently regardless of this fix.I built and ran this locally against a
ros:rolling-ros-basecontainer (colcon,-DBUILD_TESTING=ON), both with a plain build and with ThreadSanitizer. Unpatched, ThreadSanitizer reliably reports theaddOwningClassLoader()/removeOwningClassLoader()race onassociated_class_loaders_(9-10 reports per run); with the fix, zero reports across many runs. The fullcolcon test --packages-select class_loadersuite passes 100% (9/9) with the fix applied.Generative AI
This fix was investigated, written, and iteratively verified (ThreadSanitizer, stress-testing, and a local colcon/ROS 2 rolling build) by Claude (Anthropic), operating as Claude Code, at the direction of and reviewed by me. The regression test and this PR description were also written by Claude; I reviewed both before submitting.