diff --git a/docs/usr/modules/plugins.md b/docs/usr/modules/plugins.md index f6f991b8..e7b45618 100644 --- a/docs/usr/modules/plugins.md +++ b/docs/usr/modules/plugins.md @@ -16,6 +16,9 @@ print(plugins.isLoaded("plugins/gameplay.so") + "\n"); `Plugins` 加载动态库;库导出的 `eve_plugin_init` 触发模块注册;随后脚本从 `eve` 创建新模块。插件二进制与宿主共享 C++ ABI。 +成功加载的插件会驻留到进程结束。引擎不会在运行期间卸载插件,因为 Squirrel +类、闭包和原生模块实例可能仍引用动态库中的代码。 + ## 目标导向指南 ### 加载平台匹配的插件 @@ -38,6 +41,8 @@ print(plugins.isLoaded("plugins/gameplay.so") + "\n"); - `getName()`、`isLoaded()`、`load()`、`unload()` +`unload()` 是兼容保留接口,始终返回 `false`;需要更新插件代码时请重启进程。 + ## 使用要点 - 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。 diff --git a/src/engine/common/Module.cpp b/src/engine/common/Module.cpp index 2a6a3208..2ca4b986 100644 --- a/src/engine/common/Module.cpp +++ b/src/engine/common/Module.cpp @@ -39,15 +39,53 @@ void ModuleManager::register_module(const char* name, creator_t c, exposer_t e) const bool hasCreator = c != nullptr; // function pointers can't be printed by zeroerr EV_PARAM_CHECK(hasCreator, "module creator must not be null"); auto p = inst().registered_modules.find(name); + if (inst().plugin_registration_active_ && p != inst().registered_modules.end()) { + if (inst().plugin_registration_error_.empty()) + inst().plugin_registration_error_ = + std::string("module name already registered: ") + name; + return; + } if (p == inst().registered_modules.end()) { inst().registered_modules.insert( - std::make_pair(std::string(name), - ModuleInfo{c, e, nullptr})); + std::make_pair(std::string(name), ModuleInfo{c, e, nullptr})); + if (inst().plugin_registration_active_) + inst().plugin_registration_added_.emplace_back(name); } else { p->second = {c, e, nullptr}; } } +bool ModuleManager::beginPluginRegistration() { + auto& manager = inst(); + if (manager.plugin_registration_active_) + return false; + manager.plugin_registration_active_ = true; + manager.plugin_registration_added_.clear(); + manager.plugin_registration_error_.clear(); + return true; +} + +std::string ModuleManager::finishPluginRegistration(bool commit) { + auto& manager = inst(); + if (!manager.plugin_registration_active_) + return "no plugin registration transaction is active"; + + const std::string error = manager.plugin_registration_error_; + if (!commit || !error.empty()) { + for (const auto& name : manager.plugin_registration_added_) { + auto it = manager.registered_modules.find(name); + if (it == manager.registered_modules.end()) + continue; + delete it->second.instance; + manager.registered_modules.erase(it); + } + } + manager.plugin_registration_active_ = false; + manager.plugin_registration_added_.clear(); + manager.plugin_registration_error_.clear(); + return error; +} + void ModuleManager::exposeVM(ssq::VM& vm) { ssq::Table table; // Runtime::initialize() guarantees expose() runs once, so addTable never diff --git a/src/engine/common/Module.h b/src/engine/common/Module.h index 0526dabf..fd1d491d 100644 --- a/src/engine/common/Module.h +++ b/src/engine/common/Module.h @@ -4,6 +4,7 @@ #include #include #include +#include namespace ssq { class VM; @@ -61,6 +62,13 @@ public: /** @brief Registers a module factory (creator + script exposer) by name. */ static void register_module(const char* name, creator_t c, exposer_t e); + /** @brief Begins an atomic native-plugin module registration transaction. */ + static bool beginPluginRegistration(); + /** @brief Commits or rolls back the active plugin registration transaction. + * @param commit Whether to keep newly registered modules. + * @return Empty on success, otherwise a description of the registration conflict. + */ + static std::string finishPluginRegistration(bool commit); /** @brief Exposes every registered module into the given runtime's root table. */ static void expose(Runtime& runtime); // Compatibility for embedders that still own their ssq::VM directly. @@ -107,6 +115,9 @@ protected: }; std::unordered_map registered_modules; + bool plugin_registration_active_ = false; + std::vector plugin_registration_added_; + std::string plugin_registration_error_; Runtime* active_runtime_ = nullptr; }; diff --git a/src/modules/plugins/Plugins.cpp b/src/modules/plugins/Plugins.cpp index 81cd7d30..b4cd5824 100644 --- a/src/modules/plugins/Plugins.cpp +++ b/src/modules/plugins/Plugins.cpp @@ -20,15 +20,8 @@ Module_IMPL(Plugins, new Plugins()); Plugins::Plugins() = default; Plugins::~Plugins() { - for (auto& kv : loaded_) { -#if defined(_WIN32) - if (kv.second.native) - FreeLibrary(static_cast(kv.second.native)); -#else - if (kv.second.native) - dlclose(kv.second.native); -#endif - } + // Module factories and Squirrel closures point into plugin code. Keep every + // successfully loaded library resident until process teardown. loaded_.clear(); } @@ -37,60 +30,87 @@ bool Plugins::load(const std::string& path) { throw Exception("plugins.load: empty path"); if (loaded_.count(path)) return true; + if (!ModuleManager::beginPluginRegistration()) + throw Exception("plugins.load: nested plugin loading is not supported"); #if defined(_WIN32) HMODULE mod = LoadLibraryA(path.c_str()); - if (!mod) + if (!mod) { + const DWORD error = GetLastError(); + ModuleManager::finishPluginRegistration(false); throw Exception("plugins.load: LoadLibrary failed for '%s' (err=%lu)", path.c_str(), - GetLastError()); + error); + } auto init = reinterpret_cast(GetProcAddress(mod, "eve_plugin_init")); if (!init) { + ModuleManager::finishPluginRegistration(false); FreeLibrary(mod); throw Exception("plugins.load: missing eve_plugin_init in '%s'", path.c_str()); } - int rc = init(); + int rc = -1; + try { + rc = init(); + } catch (...) { + ModuleManager::finishPluginRegistration(false); + FreeLibrary(mod); + throw Exception("plugins.load: eve_plugin_init threw for '%s'", path.c_str()); + } if (rc != 0) { + ModuleManager::finishPluginRegistration(false); FreeLibrary(mod); throw Exception("plugins.load: eve_plugin_init returned %d for '%s'", rc, path.c_str()); } - loaded_[path] = Handle{mod, path}; #else void* mod = dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL); - if (!mod) + if (!mod) { + ModuleManager::finishPluginRegistration(false); throw Exception("plugins.load: dlopen failed for '%s': %s", path.c_str(), dlerror()); + } dlerror(); auto init = reinterpret_cast(dlsym(mod, "eve_plugin_init")); const char* err = dlerror(); if (err != nullptr || !init) { const std::string error = err ? err : ""; + ModuleManager::finishPluginRegistration(false); dlclose(mod); throw Exception("plugins.load: missing eve_plugin_init in '%s'%s%s", path.c_str(), error.empty() ? "" : ": ", error.c_str()); } - int rc = init(); + int rc = -1; + try { + rc = init(); + } catch (...) { + ModuleManager::finishPluginRegistration(false); + dlclose(mod); + throw Exception("plugins.load: eve_plugin_init threw for '%s'", path.c_str()); + } if (rc != 0) { + ModuleManager::finishPluginRegistration(false); dlclose(mod); throw Exception("plugins.load: eve_plugin_init returned %d for '%s'", rc, path.c_str()); } - loaded_[path] = Handle{mod, path}; #endif + const std::string registrationError = ModuleManager::finishPluginRegistration(true); + if (!registrationError.empty()) { +#if defined(_WIN32) + FreeLibrary(mod); +#else + dlclose(mod); +#endif + throw Exception("plugins.load: %s in '%s'", registrationError.c_str(), path.c_str()); + } + loaded_[path] = Handle{mod, path}; + if (ModuleManager::expose_pending() < 0) - throw Exception("plugins.load: expose_pending failed after loading '%s'", path.c_str()); + throw Exception("plugins.load: expose_pending failed after loading '%s'; plugin retained", + path.c_str()); return true; } bool Plugins::unload(const std::string& path) { - auto it = loaded_.find(path); - if (it == loaded_.end()) - return false; -#if defined(_WIN32) - FreeLibrary(static_cast(it->second.native)); -#else - dlclose(it->second.native); -#endif - loaded_.erase(it); - return true; + (void) path; + return false; } bool Plugins::isLoaded(const std::string& path) const { diff --git a/src/modules/plugins/Plugins.h b/src/modules/plugins/Plugins.h index 2faaf37b..0c52e2c6 100644 --- a/src/modules/plugins/Plugins.h +++ b/src/modules/plugins/Plugins.h @@ -16,7 +16,7 @@ public: /** @brief 加载插件;路径可为绝对路径或游戏相对路径。 */ bool load(const std::string& path); - /** @brief 卸载插件。 */ + /** @brief 插件为进程期驻留;该兼容方法始终返回 false。 */ bool unload(const std::string& path); /** @brief 插件是否已加载。 */ bool isLoaded(const std::string& path) const; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1c15fcd3..627aaae3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -397,7 +397,26 @@ else() endif() target_compile_definitions(unit_test PRIVATE EVENGINE_TEST_BINARY_DIR="${CMAKE_CURRENT_BINARY_DIR}" - EVENGINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}") + EVENGINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + EVENGINE_TEST_SHARED_LIBRARY_SUFFIX="${CMAKE_SHARED_LIBRARY_SUFFIX}") + + # A real shared library exercises native loading and C++ module registration + # instead of testing only loader failures. It resolves engine symbols from + # the unit-test host exactly as a shipped plugin resolves them from eve. + add_library(native_test_plugin SHARED fixtures/native_test_plugin.cpp) + target_compile_features(native_test_plugin PRIVATE cxx_std_20) + target_compile_definitions(native_test_plugin PRIVATE EVENGINE_PLUGIN=1) + target_link_libraries(native_test_plugin PRIVATE eve_engine_includes) + set_target_properties(native_test_plugin PROPERTIES PREFIX "") + if(WIN32) + set_target_properties(unit_test PROPERTIES ENABLE_EXPORTS ON) + target_link_libraries(native_test_plugin PRIVATE "$") + elseif(APPLE) + target_link_options(native_test_plugin PRIVATE "LINKER:-undefined,dynamic_lookup") + target_link_options(unit_test PRIVATE "LINKER:-export_dynamic") + elseif(UNIX) + target_link_options(unit_test PRIVATE "LINKER:--export-dynamic") + endif() endif() # Large classic scenes (Sponza / DamagedHelmet). Cornell Box is committed. diff --git a/test/fixtures/native_test_plugin.cpp b/test/fixtures/native_test_plugin.cpp new file mode 100644 index 00000000..7f1a377d --- /dev/null +++ b/test/fixtures/native_test_plugin.cpp @@ -0,0 +1,22 @@ +#include "common/Export.h" +#include "common/Module.h" + +#include + +namespace eve::plugins::test { + +class NativeTestPlugin : public Module { +public: + Module_REG(NativeTestPlugin); + +}; + +Module_IMPL(NativeTestPlugin, new NativeTestPlugin()); + +void NativeTestPlugin::expose(ssq::Table&) {} + +void NativeTestPlugin::expose(ssq::Class&) {} + +} // namespace eve::plugins::test + +EVE_PLUGIN_EXPORT int eve_plugin_init(void) { return 0; } diff --git a/test/plugins.cpp b/test/plugins.cpp index 65351b3d..771eec83 100644 --- a/test/plugins.cpp +++ b/test/plugins.cpp @@ -1,6 +1,7 @@ #include "zeroerr/assert.h" #include "zeroerr/unittest.h" +#include "common/Runtime.h" #include "plugins/Plugins.h" #include @@ -38,3 +39,26 @@ TEST_CASE("plugins.unload.unknownPathIsNoop") { Plugins plugins; CHECK_EQ(plugins.unload("not-loaded"), false); } + +#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) +TEST_CASE("plugins.load.nativeLibraryAndInstantiateCppModule") { + eve::Runtime runtime(512, ssq::Libs::ALL); + runtime.initialize(); + + Plugins plugins; + const std::string library = + std::string(EVENGINE_TEST_BINARY_DIR) + "/native_test_plugin" + + EVENGINE_TEST_SHARED_LIBRARY_SUFFIX; + REQUIRE(plugins.load(library)); + CHECK(plugins.isLoaded(library)); + + auto* module = eve::ModuleManager::requireInstance("NativeTestPlugin"); + REQUIRE(module != nullptr); + CHECK_EQ(module->getName(), std::string("NativeTestPlugin")); + + // Native classes and VM closures still point into the library, so plugins + // deliberately remain resident for the rest of the process. + CHECK_EQ(plugins.unload(library), false); + CHECK(plugins.isLoaded(library)); +} +#endif