Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions include/class_loader/class_loader_core.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -346,21 +346,31 @@ requires InterfaceConstructible<Base, Args...>
Base * createInstance(const std::string & derived_class_name, ClassLoader * loader, Args &&... args)
{
AbstractMetaObject<Base> * factory = nullptr;
bool owned_by_loader = false;
bool owned_by_none = false;

getPluginBaseToFactoryMapMapMutex().lock();
FactoryMap & factoryMap = getFactoryMapForBaseClass<Base>();
if (auto it = factoryMap.find(derived_class_name); it != factoryMap.end()) {
factory = dynamic_cast<impl::AbstractMetaObject<Base> *>(it->second);
} else {
CONSOLE_BRIDGE_logError(
"class_loader.impl: No metaobject exists for class type %s.", derived_class_name.c_str());
{
std::lock_guard<std::recursive_mutex> lock(getPluginBaseToFactoryMapMapMutex());
FactoryMap & factoryMap = getFactoryMapForBaseClass<Base>();
if (auto it = factoryMap.find(derived_class_name); it != factoryMap.end()) {
factory = dynamic_cast<impl::AbstractMetaObject<Base> *>(it->second);
} else {
CONSOLE_BRIDGE_logError(
"class_loader.impl: No metaobject exists for class type %s.", derived_class_name.c_str());
}
if (factory != nullptr) {
// 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.
Comment on lines +362 to +364

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

owned_by_loader = factory->isOwnedBy(loader);
owned_by_none = factory->isOwnedBy(nullptr);
}
}
getPluginBaseToFactoryMapMapMutex().unlock();

Base * obj = nullptr;
if (factory != nullptr && factory->isOwnedBy(loader)) {
if (factory != nullptr && owned_by_loader) {
obj = factory->create(std::forward<Args>(args)...);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()?

} else if (factory && factory->isOwnedBy(nullptr)) {
} else if (factory && owned_by_none) {
CONSOLE_BRIDGE_logDebug(
"%s",
"class_loader.impl: ALERT!!! "
Expand Down
11 changes: 11 additions & 0 deletions src/meta_object.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

#include <algorithm>
#include <memory>
#include <mutex>
#include <string>
#include <vector>

Expand All @@ -45,6 +46,10 @@ using ClassLoaderVector = std::vector<class_loader::ClassLoader *>;
class AbstractMetaObjectBaseImpl
{
public:
// Guards associated_class_loaders_: class_loader_core.hpp's createInstance() reads
// isOwnedBy() after releasing getPluginBaseToFactoryMapMapMutex(), so this vector needs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After this PR, createInstance() no longer reads isOwnedBy() after releasing etPluginBaseToFactoryMapMapMutex(). This comment describes the old code?

// its own lock independent of that outer mutex.
mutable std::recursive_mutex associated_class_loaders_mutex_;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

ClassLoaderVector associated_class_loaders_;
std::string associated_library_path_;
std::string base_class_name_;
Expand Down Expand Up @@ -102,6 +107,7 @@ void AbstractMetaObjectBase::setAssociatedLibraryPath(const std::string & librar

void AbstractMetaObjectBase::addOwningClassLoader(ClassLoader * loader)
{
std::lock_guard<std::recursive_mutex> lock(impl_->associated_class_loaders_mutex_);
ClassLoaderVector & v = impl_->associated_class_loaders_;
if (std::ranges::find(v, loader) == v.end()) {
v.push_back(loader);
Expand All @@ -110,6 +116,7 @@ void AbstractMetaObjectBase::addOwningClassLoader(ClassLoader * loader)

void AbstractMetaObjectBase::removeOwningClassLoader(const ClassLoader * loader)
{
std::lock_guard<std::recursive_mutex> lock(impl_->associated_class_loaders_mutex_);
ClassLoaderVector & v = impl_->associated_class_loaders_;
if (auto itr = std::ranges::find(v, loader); itr != v.end()) {
v.erase(itr);
Expand All @@ -118,22 +125,26 @@ void AbstractMetaObjectBase::removeOwningClassLoader(const ClassLoader * loader)

bool AbstractMetaObjectBase::isOwnedBy(const ClassLoader * loader) const
{
std::lock_guard<std::recursive_mutex> lock(impl_->associated_class_loaders_mutex_);
const ClassLoaderVector & v = impl_->associated_class_loaders_;
return std::ranges::find(v, loader) != v.end();
}

bool AbstractMetaObjectBase::isOwnedByAnybody() const
{
std::lock_guard<std::recursive_mutex> lock(impl_->associated_class_loaders_mutex_);
return impl_->associated_class_loaders_.size() > 0;
}

size_t AbstractMetaObjectBase::getAssociatedClassLoadersCount() const
{
std::lock_guard<std::recursive_mutex> lock(impl_->associated_class_loaders_mutex_);
return impl_->associated_class_loaders_.size();
}

ClassLoader * AbstractMetaObjectBase::getAssociatedClassLoader(size_t index) const
{
std::lock_guard<std::recursive_mutex> lock(impl_->associated_class_loaders_mutex_);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

return impl_->associated_class_loaders_[index];
}

Expand Down
45 changes: 45 additions & 0 deletions test/utest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* POSSIBILITY OF SUCH DAMAGE.
*/

#include <atomic>
#include <chrono>
#include <cstddef>

Expand Down Expand Up @@ -307,6 +308,50 @@ TEST(ClassLoaderTest, threadSafety) {
}
}

// Regression test: unlike threadSafety above (one ClassLoader shared by every thread, so
// isOwnedBy() is always queried with the loader that already, and permanently, owns the
// metaobject), this constructs a *separate* ClassLoader per thread for the same library.
// Each construction/destruction concurrently mutates AbstractMetaObjectBase's
// associated_class_loaders_ vector (via addOwningClassLoader()/removeOwningClassLoader())
// while other threads concurrently read it via isOwnedBy() in createInstance() - previously
// unguarded, since createInstance() released getPluginBaseToFactoryMapMapMutex() before
// reading isOwnedBy(), and addOwningClassLoader()/removeOwningClassLoader()/isOwnedBy() took
// no lock of their own. Reported by ThreadSanitizer as a data race on the vector's
// push_back()/erase() (via std::vector's internal reallocation).
//
// `anchor` is kept alive for the whole test so the library's owner count never reaches zero
// and none of the worker threads ever trigger the actual unload/dlclose path: that path has
// its own pre-existing, unrelated bug (a library can be dlclose()-d twice when independent
// ClassLoaders for the same library race to be "last owner out"), which this test isn't
// targeting and would otherwise crash it intermittently regardless of the fix under test here.
TEST(ClassLoaderTest, threadSafetyMultipleLoadersPerLibrary) {
class_loader::ClassLoader anchor(LIBRARY_1);

std::vector<std::thread> client_threads;
std::atomic<int> failures{0};

for (size_t c = 0; c < STRESS_TEST_NUM_THREADS; ++c) {
client_threads.emplace_back(
[&failures]() {
try {
class_loader::ClassLoader loader(LIBRARY_1);
std::vector<std::string> classes = loader.getAvailableClasses<Base>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we want to assert that classes isn't empty so that we know we are actually testing something?

for (auto & class_name : classes) {
loader.createInstance<Base>(class_name)->saySomething();
}
} catch (...) {
++failures;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we actually catch the exception and print it here so that we can get something if there is a failure?

}
});
}

for (auto & client_thread : client_threads) {
client_thread.join();
}

EXPECT_EQ(0, failures.load());
}

TEST(ClassLoaderTest, loadRefCountingNonLazy) {
try {
class_loader::ClassLoader loader1(LIBRARY_1, false);
Expand Down