From 7de26876d139c68ad74223429f1cbcf0cca94b19 Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 20 Oct 2026 09:46:40 +0800 Subject: [PATCH 1/2] fix(app): report startup phase and module failures --- docs/guides/applications.md | 2 +- docs/guides/applications_CN.md | 2 +- docs/guides/modules.md | 4 +- docs/guides/modules_CN.md | 4 +- include/ark/plugin_manager.hpp | 16 ++++ src/app/main.cpp | 29 +++++- tests/app/startup_failure_plugin.cpp | 29 +++++- tests/app/startup_failures.cmake | 56 +++++++++--- tests/core/README.md | 2 + tests/core/README_CN.md | 2 + tests/core/business_runtime.cpp | 3 +- tests/core/plugin_manager_startup.cpp | 125 ++++++++++++++++++++++---- 12 files changed, 229 insertions(+), 45 deletions(-) diff --git a/docs/guides/applications.md b/docs/guides/applications.md index 98b63c1b5..f07bc5e92 100644 --- a/docs/guides/applications.md +++ b/docs/guides/applications.md @@ -87,7 +87,7 @@ If a shutdown hook returns `false` or throws, the launcher reports its phase, mo diff --git a/docs/guides/applications_CN.md b/docs/guides/applications_CN.md index 1a9642f80..87efa31b4 100644 --- a/docs/guides/applications_CN.md +++ b/docs/guides/applications_CN.md @@ -87,7 +87,7 @@ macOS/Linux 可使用 Ctrl+C 或 `SIGTERM`,由应用主循环处理停止请 diff --git a/docs/guides/modules.md b/docs/guides/modules.md index 84bb820e2..23054a134 100644 --- a/docs/guides/modules.md +++ b/docs/guides/modules.md @@ -104,9 +104,9 @@ Normal shutdown first stops managed top-level task admission and keeps pumping a diff --git a/docs/guides/modules_CN.md b/docs/guides/modules_CN.md index 85863f8cc..3c32ac86a 100644 --- a/docs/guides/modules_CN.md +++ b/docs/guides/modules_CN.md @@ -104,9 +104,9 @@ private: diff --git a/include/ark/plugin_manager.hpp b/include/ark/plugin_manager.hpp index 862d2de57..272bcd1ff 100644 --- a/include/ark/plugin_manager.hpp +++ b/include/ark/plugin_manager.hpp @@ -106,6 +106,7 @@ class plugin_manager final : private noncopyable throw std::logic_error("Plugin manager cannot start during a lifecycle call or after shutdown begins"); } phase_guard active(*this, call_phase::starting); + startup_failure_.clear(); timestamp_ = timestamp; plugin_library_dir_ = lib_dir; @@ -241,6 +242,11 @@ class plugin_manager final : private noncopyable return pending_tasks_unchecked(); } + const std::string& get_startup_failure() const { + check_owner(); + return startup_failure_; + } + shutdown_state get_shutdown_state() const { check_owner(); return shutdown_state_; @@ -446,6 +452,7 @@ class plugin_manager final : private noncopyable for (auto const& iter : plugins_) { phase_guard installing(*this, call_phase::installing); + startup_failure_ = "load_plugin: " + iter.first; bool ret = load_plugin_library(iter.first); ARK_ASSERT_RET_VAL(ret, false); } @@ -456,12 +463,14 @@ class plugin_manager final : private noncopyable module* module_ptr = iter.second; ARK_ASSERT_CONTINUE(module_ptr != nullptr); + startup_failure_ = "init: " + iter.first; if (!module_ptr->init()) { return false; } } + startup_failure_.clear(); return true; } @@ -471,12 +480,14 @@ class plugin_manager final : private noncopyable module* module_ptr = iter.second; ARK_ASSERT_CONTINUE(module_ptr != nullptr); + startup_failure_ = "post_init: " + iter.first; if (!module_ptr->post_init()) { return false; } } + startup_failure_.clear(); return true; } @@ -486,12 +497,14 @@ class plugin_manager final : private noncopyable module* module_ptr = iter.second; ARK_ASSERT_CONTINUE(module_ptr != nullptr); + startup_failure_ = "check_config: " + iter.first; if (!module_ptr->check_config()) { return false; } } + startup_failure_.clear(); return true; } @@ -501,12 +514,14 @@ class plugin_manager final : private noncopyable module* module_ptr = iter.second; ARK_ASSERT_CONTINUE(module_ptr != nullptr); + startup_failure_ = "pre_update: " + iter.first; if (!module_ptr->pre_update()) { return false; } } + startup_failure_.clear(); return true; } @@ -682,6 +697,7 @@ class plugin_manager final : private noncopyable shutdown_state shutdown_state_{shutdown_state::running}; std::exception_ptr shutdown_exception_; std::string shutdown_failure_; + std::string startup_failure_; // Bus id int bus_id_{0}; diff --git a/src/app/main.cpp b/src/app/main.cpp index 04d153ed8..5f1331484 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -290,6 +290,16 @@ bool main_loop() { #endif } +void report_startup_failure(const char* detail) { + std::cerr << "Application startup failed"; + const auto& failure = plugin_manager::instance()->get_startup_failure(); + if (!failure.empty()) + { + std::cerr << " at " << failure; + } + std::cerr << ": " << detail << std::endl; +} + int main(int argc, char* argv[]) { std::chrono::milliseconds shutdown_timeout{}; if (!parse_args(argc, argv, shutdown_timeout)) @@ -310,10 +320,23 @@ int main(int argc, char* argv[]) { return 1; } - if (!plugin_manager::instance()->start( - date_time::get_now_time(), manifest.plugin_library_dir, manifest.plugin_config_dir, manifest.plugins)) + try + { + if (!plugin_manager::instance()->start( + date_time::get_now_time(), manifest.plugin_library_dir, manifest.plugin_config_dir, manifest.plugins)) + { + report_startup_failure("returned false"); + return 1; + } + } + catch (const std::exception& error) + { + report_startup_failure(error.what()); + return 1; + } + catch (...) { - std::cerr << "Application startup failed" << std::endl; + report_startup_failure("unknown exception"); return 1; } diff --git a/tests/app/startup_failure_plugin.cpp b/tests/app/startup_failure_plugin.cpp index 33ef99dc2..f50303c74 100644 --- a/tests/app/startup_failure_plugin.cpp +++ b/tests/app/startup_failure_plugin.cpp @@ -1,6 +1,7 @@ // Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. #include #include +#include #include #include #include @@ -13,7 +14,9 @@ class startup_failure_module final : public ark::module { public: void fail_at(std::string phase) { - failed_phase_ = std::move(phase); + const auto separator = phase.find(':'); + behavior_ = separator == std::string::npos ? "false" : phase.substr(0, separator); + failed_phase_ = separator == std::string::npos ? std::move(phase) : phase.substr(separator + 1); } const std::string& get_name() const override { @@ -58,10 +61,23 @@ class startup_failure_module final : public ark::module private: bool record(std::string_view phase) { std::cerr << "app-startup-test:" << phase << '\n'; - return phase != failed_phase_; + if (phase == failed_phase_) + { + if (behavior_ == "throw") + { + throw std::runtime_error("test startup exception in " + std::string(phase)); + } + if (behavior_ == "unknown") + { + throw 7; + } + return false; + } + return true; } std::string failed_phase_; + std::string behavior_; bool updated_{}; }; @@ -73,15 +89,22 @@ startup_failure_module fixture; ARK_EXPORT_FUNC void DllEntryPlugin( ark::plugin_manager* manager, const std::string& plugin_name, ark::logger* shared_logger) { ark::logger::init(shared_logger); - fixture.fail_at(manager->get_plugin_conf(plugin_name)); + const auto& mode = manager->get_plugin_conf(plugin_name); + fixture.fail_at(mode); if (!manager->add_module(fixture.get_name(), &fixture) || !manager->add_update_module(&fixture)) { std::cerr << "Startup test module registration failed\n"; std::abort(); } + if (mode == "throw:load_plugin") + { + std::cerr << "app-startup-test:load_plugin\n"; + throw std::runtime_error("test plugin entry exception"); + } } ARK_EXPORT_FUNC void DllExitPlugin(ark::plugin_manager* manager) { + std::cerr << "app-startup-test:uninstall\n"; manager->remove_update_module(fixture.get_name()); manager->remove_module(fixture.get_name()); } diff --git a/tests/app/startup_failures.cmake b/tests/app/startup_failures.cmake index 633906025..db88ada12 100644 --- a/tests/app/startup_failures.cmake +++ b/tests/app/startup_failures.cmake @@ -15,17 +15,21 @@ string(REPLACE "<" "<" xml_plugin_directory "${xml_plugin_directory}") string(REPLACE "\"" "\\\"" toml_plugin_directory "${plugin_directory}") set(plugin_name ark_app_startup_failure_plugin) -foreach(failed_phase init post_init check_config pre_update) +foreach(mode false:init false:post_init false:check_config false:pre_update + throw:init throw:post_init throw:check_config throw:pre_update unknown:init throw:load_plugin) + string(REPLACE ":" ";" parts "${mode}") + list(GET parts 0 behavior) + list(GET parts 1 failed_phase) foreach(format toml app.conf) - set(manifest "${RUNTIME_DIR}/${failed_phase}.${format}") + set(manifest "${RUNTIME_DIR}/${behavior}-${failed_phase}.${format}") if(format STREQUAL "toml") file(WRITE "${manifest}" "[application]\nplugin_library_dir = \"${toml_plugin_directory}\"\n" - "[[plugins]]\nname = \"${plugin_name}\"\nconfig = \"${failed_phase}\"\n") + "[[plugins]]\nname = \"${plugin_name}\"\nconfig = \"${mode}\"\n") else() file(WRITE "${manifest}" "" - "\n") + "\n") endif() execute_process( COMMAND "${PROGRAM}" --busid=1.0.1.1 --name=startup-error-test @@ -42,20 +46,46 @@ foreach(failed_phase init post_init check_config pre_update) if(NOT result STREQUAL "1") message(FATAL_ERROR "${failed_phase}/${format}: expected normal exit 1, got ${result}\n${output}\n${errors}") endif() - if(NOT errors MATCHES "Application startup failed") - message(FATAL_ERROR "${failed_phase}/${format}: missing startup diagnostic\n${errors}") + if(failed_phase STREQUAL "load_plugin") + set(owner "${plugin_name}") + else() + set(owner app_startup_failure_module) endif() - set(expected) - foreach(phase init post_init check_config pre_update) - list(APPEND expected "app-startup-test:${phase}") - if(phase STREQUAL failed_phase) - break() + if(NOT errors MATCHES "Application startup failed at ${failed_phase}: ${owner}") + message(FATAL_ERROR "${mode}/${format}: missing phase/module-or-plugin startup diagnostic\n${errors}") + endif() + string(REGEX MATCHALL "Application startup failed" diagnostics "${errors}") + list(LENGTH diagnostics count) + if(NOT count EQUAL 1) + message(FATAL_ERROR "${mode}/${format}: startup failure must be reported once") + endif() + if(behavior STREQUAL "throw") + if(failed_phase STREQUAL "load_plugin") + set(exception "test plugin entry exception") + else() + set(exception "test startup exception in ${failed_phase}") + endif() + if(NOT errors MATCHES "${exception}") + message(FATAL_ERROR "${mode}/${format}: exception details were lost\n${errors}") endif() - endforeach() + elseif(behavior STREQUAL "unknown" AND NOT errors MATCHES "unknown exception") + message(FATAL_ERROR "${mode}/${format}: unknown exception was not identified\n${errors}") + endif() + set(expected) + if(failed_phase STREQUAL "load_plugin") + list(APPEND expected "app-startup-test:load_plugin") + else() + foreach(phase init post_init check_config pre_update) + list(APPEND expected "app-startup-test:${phase}") + if(phase STREQUAL failed_phase) + break() + endif() + endforeach() + endif() string(REGEX MATCHALL "app-startup-test:[a-z_]+" observed "${errors}") if(NOT observed STREQUAL expected) message(FATAL_ERROR "${failed_phase}/${format}: unexpected lifecycle calls\nexpected=${expected}\nobserved=${observed}\n${errors}") endif() - message(STATUS "${failed_phase}/${format}: normal exit 1; no later phase, update or shutdown call") + message(STATUS "${mode}/${format}: diagnostic and normal exit 1; no later phase, update, shutdown or uninstall call") endforeach() endforeach() diff --git a/tests/core/README.md b/tests/core/README.md index 4a0c4ec08..ddd335b64 100644 --- a/tests/core/README.md +++ b/tests/core/README.md @@ -31,6 +31,8 @@ launcher against missing, malformed and mistyped manifests. It requires normal exit code 1 with a manifest diagnostic, including on Windows, and is included in `check-core`. +`check-app-startup-failures` uses the real launcher and a test DSO for all four startup phases, each returning false or throwing, with both TOML and XML manifests. It also checks a non-standard exception and a throwing plugin entrypoint. All 20 children must report one exact phase/module-or-plugin diagnostic, preserve available exception details, and exit normally with code 1 without later startup calls, update, normal shutdown or uninstall. Core tests check retained failure context, unchanged exception propagation, first failure across multiple modules and owner-thread access. These tests establish failure reporting and process exit, not partial-start resource cleanup or same-process recovery. + `check-kernel-initialization` loads real metadata and static data from `resource/meta/`. It checks four kernel/config initialization orders with both resource-relative paths and legacy paths beginning with `/`, for eight combinations. diff --git a/tests/core/README_CN.md b/tests/core/README_CN.md index b5bed1413..61a75cbaa 100644 --- a/tests/core/README_CN.md +++ b/tests/core/README_CN.md @@ -27,6 +27,8 @@ cmake --build core-build --target check-core 字段类型错误的清单,要求正常返回退出码 1 并输出清单诊断。该入口覆盖 Windows, 并纳入 `check-core`。 +`check-app-startup-failures` 使用真实启动器和测试动态库,覆盖四个启动阶段各自返回 false 或抛出异常,并分别读取 TOML 和 XML 清单;还验证非标准异常和插件入口抛出异常。20 个子进程均须输出一次准确的阶段、模块或插件诊断,保留可用异常信息,以正常退出码 1 结束,且不执行后续启动调用、update、正常关闭或卸载。核心用例检查失败上下文、原始异常传播、多模块中的首次失败停止及所属线程访问。这些测试证明失败报告与进程退出,不证明部分启动资源清理或同进程恢复。 + `check-kernel-initialization` 从 `resource/meta/` 加载真实元数据和静态数据, 分别使用资源相对路径及以 `/` 开头的旧路径,验证内核与配置模块的四种初始化顺序,共八种组合。 diff --git a/tests/core/business_runtime.cpp b/tests/core/business_runtime.cpp index c2b7d8193..2f8e29871 100644 --- a/tests/core/business_runtime.cpp +++ b/tests/core/business_runtime.cpp @@ -348,6 +348,7 @@ TEST_CASE("Manager owner and active registry checks reject mutations before they rejects([&] { manager.get_tasks(module); }); rejects([&] { manager.create_task_group(extra); }); rejects([&] { manager.pending_tasks(); }); + rejects([&] { manager.get_startup_failure(); }); rejects([&] { manager.get_shutdown_state(); }); rejects([&] { manager.get_shutdown_exception(); }); rejects([&] { manager.get_shutdown_failure(); }); @@ -361,7 +362,7 @@ TEST_CASE("Manager owner and active registry checks reject mutations before they rejects([&] { tasks.start(ready_task()); }); }); worker.join(); - CHECK(rejected == 15); + CHECK(rejected == 16); module.on_update = [&] { CHECK_THROWS_AS(manager.update(1), std::logic_error); CHECK_THROWS_AS(manager.remove_module(module.get_name()), std::logic_error); diff --git a/tests/core/plugin_manager_startup.cpp b/tests/core/plugin_manager_startup.cpp index 7d3139641..3b105ad29 100644 --- a/tests/core/plugin_manager_startup.cpp +++ b/tests/core/plugin_manager_startup.cpp @@ -1,6 +1,7 @@ // Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. #include #include +#include #include #include "../network/doctest.h" @@ -20,11 +21,20 @@ enum class startup_phase : std::size_t constexpr std::array phase_names{"init", "post_init", "check_config", "pre_update"}; +enum class failure_mode +{ + false_result, + standard_exception, + unknown_exception +}; + class counted_module final : public ark::module { public: - explicit counted_module(startup_phase failed_phase = startup_phase::none) - : failed_phase_(failed_phase) { + explicit counted_module( + startup_phase failed_phase = startup_phase::none, failure_mode mode = failure_mode::false_result) + : failed_phase_(failed_phase) + , mode_(mode) { } bool init() override { @@ -43,15 +53,44 @@ class counted_module final : public ark::module return record(startup_phase::pre_update); } + bool update() override { + ++late_calls; + return true; + } + + bool pre_shut() override { + ++late_calls; + return true; + } + + bool shut() override { + ++late_calls; + return true; + } + std::array calls{}; + unsigned late_calls{}; private: bool record(startup_phase phase) { ++calls[static_cast(phase)]; - return phase != failed_phase_; + if (phase == failed_phase_) + { + if (mode_ == failure_mode::standard_exception) + { + throw std::runtime_error("startup module exception"); + } + if (mode_ == failure_mode::unknown_exception) + { + throw 91; + } + return false; + } + return true; } startup_phase failed_phase_; + failure_mode mode_; }; class unconfigured_logger_scope @@ -84,7 +123,9 @@ TEST_CASE("Plugin startup calls each successful module phase once") { REQUIRE(manager.add_module("counted-module-" + std::to_string(index), &modules[index])); } + CHECK(manager.get_startup_failure().empty()); CHECK(start(manager)); + CHECK(manager.get_startup_failure().empty()); for (const auto& module : modules) { CHECK(module.calls == std::array{1, 1, 1, 1}); @@ -100,6 +141,8 @@ TEST_CASE("Plugin startup returns false without repeating a failed phase or runn REQUIRE(manager.add_module("counted-module-single", &module)); CHECK_FALSE(start(manager)); + CHECK(manager.get_startup_failure() == std::string(phase_names[failed]) + ": counted-module-single"); + CHECK(module.late_calls == 0); for (std::size_t phase = 0; phase < phase_names.size(); ++phase) { CHECK(module.calls[phase] == (phase <= failed ? 1u : 0u)); @@ -110,26 +153,43 @@ TEST_CASE("Plugin startup returns false without repeating a failed phase or runn TEST_CASE("Plugin startup stops a phase at its first failure independently of module iteration order") { for (std::size_t failed = 0; failed < phase_names.size(); ++failed) { - CAPTURE(std::string(phase_names[failed])); - ark::plugin_manager manager; - std::array modules{counted_module(static_cast(failed)), - counted_module(static_cast(failed)), counted_module(static_cast(failed))}; - for (std::size_t index = 0; index < modules.size(); ++index) + for (const auto mode : {failure_mode::false_result, failure_mode::standard_exception}) { - REQUIRE(manager.add_module("counted-module-" + std::to_string(index), &modules[index])); - } + CAPTURE(std::string(phase_names[failed])); + ark::plugin_manager manager; + std::array modules{counted_module(static_cast(failed), mode), + counted_module(static_cast(failed), mode), + counted_module(static_cast(failed), mode)}; + for (std::size_t index = 0; index < modules.size(); ++index) + { + REQUIRE(manager.add_module("counted-module-" + std::to_string(index), &modules[index])); + } - CHECK_FALSE(start(manager)); - for (std::size_t phase = 0; phase < phase_names.size(); ++phase) - { - unsigned phase_calls = 0; - for (const auto& module : modules) + if (mode == failure_mode::false_result) + { + CHECK_FALSE(start(manager)); + } + else { - CHECK(module.calls[phase] <= 1); - phase_calls += module.calls[phase]; + CHECK_THROWS_WITH_AS(start(manager), "startup module exception", std::runtime_error); + } + for (std::size_t phase = 0; phase < phase_names.size(); ++phase) + { + unsigned phase_calls = 0; + for (const auto& module : modules) + { + CHECK(module.calls[phase] <= 1); + phase_calls += module.calls[phase]; + if (phase == failed && module.calls[phase] == 1) + { + const auto index = static_cast(&module - modules.data()); + CHECK(manager.get_startup_failure() == + std::string(phase_names[failed]) + ": counted-module-" + std::to_string(index)); + } + } + const auto expected = phase < failed ? modules.size() : phase == failed ? 1u : 0u; + CHECK(phase_calls == expected); } - const auto expected = phase < failed ? modules.size() : phase == failed ? 1u : 0u; - CHECK(phase_calls == expected); } } } @@ -145,3 +205,30 @@ TEST_CASE("Plugin startup returns false without a configured logger") { CHECK_FALSE(started); CHECK(module.calls == std::array{1, 0, 0, 0}); } + +TEST_CASE("Startup exceptions preserve the first module and phase without cleanup or exception translation") { + for (std::size_t failed = 0; failed < phase_names.size(); ++failed) + { + for (const auto mode : {failure_mode::standard_exception, failure_mode::unknown_exception}) + { + CAPTURE(std::string(phase_names[failed])); + ark::plugin_manager manager; + counted_module module(static_cast(failed), mode); + REQUIRE(manager.add_module("throwing-module", &module)); + if (mode == failure_mode::standard_exception) + { + CHECK_THROWS_WITH_AS(start(manager), "startup module exception", std::runtime_error); + } + else + { + CHECK_THROWS_AS(start(manager), int); + } + CHECK(manager.get_startup_failure() == std::string(phase_names[failed]) + ": throwing-module"); + CHECK(module.late_calls == 0); + for (std::size_t phase = 0; phase < phase_names.size(); ++phase) + { + CHECK(module.calls[phase] == (phase <= failed ? 1u : 0u)); + } + } + } +} From 641699f70ee75a23ed8435fd300a86e93298b772 Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 20 Oct 2026 09:55:44 +0800 Subject: [PATCH 2/2] fix(app): propagate plugin load configuration failures --- docs/guides/applications.md | 2 +- docs/guides/applications_CN.md | 2 +- docs/guides/modules.md | 2 +- docs/guides/modules_CN.md | 2 +- include/ark/plugin_manager.hpp | 20 ++++++++++++++------ tests/app/missing_entry_plugin.cpp | 26 ++++++++++++++++++++++++++ tests/app/startup_failures.cmake | 25 ++++++++++++++++++------- tests/core/CMakeLists.txt | 5 ++++- tests/core/README.md | 2 +- tests/core/README_CN.md | 2 +- 10 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 tests/app/missing_entry_plugin.cpp diff --git a/docs/guides/applications.md b/docs/guides/applications.md index f07bc5e92..80a1f3726 100644 --- a/docs/guides/applications.md +++ b/docs/guides/applications.md @@ -87,7 +87,7 @@ If a shutdown hook returns `false` or throws, the launcher reports its phase, mo diff --git a/docs/guides/applications_CN.md b/docs/guides/applications_CN.md index 87efa31b4..ead0074ee 100644 --- a/docs/guides/applications_CN.md +++ b/docs/guides/applications_CN.md @@ -87,7 +87,7 @@ macOS/Linux 可使用 Ctrl+C 或 `SIGTERM`,由应用主循环处理停止请 diff --git a/docs/guides/modules.md b/docs/guides/modules.md index 23054a134..298345e10 100644 --- a/docs/guides/modules.md +++ b/docs/guides/modules.md @@ -106,7 +106,7 @@ Normal shutdown first stops managed top-level task admission and keeps pumping a The first `false` from `init()`, `post_init()`, `check_config()` or `pre_update()` stops that stage and all later startup stages, and `plugin_manager::start()` returns `false`. Each callback runs at most once in its stage. Exceptions propagate unchanged and also stop the remaining startup calls. `get_startup_failure()` retains the current `phase: module` context on failure, or `load_plugin: plugin` while loading a library and invoking its entrypoint; it is empty after successful startup and must be read on the manager's owner thread. Modules that already completed a stage are not automatically rolled back. -The application launcher reports the retained context and any exception details, then returns exit code `1` before its update loop or Windows command thread starts. This failure path does not call `pre_shut()` or `shut()` for partial-start cleanup and does not retry in the same process; deployment tools should start a fresh process. Existing assertion-based load and symbol failures remain unchanged. `update()` return values remain ignored. Keep the runtime plugin selection complete and check startup results in callers that manage their own loop. +The application launcher reports the retained context and any exception details, then returns exit code `1` before its update loop or Windows command thread starts. This failure path does not call `pre_shut()` or `shut()` for partial-start cleanup and does not retry in the same process; deployment tools should start a fresh process. Missing libraries and `DllEntryPlugin` entrypoints return false with `load_plugin: ` context. A loaded library missing its entrypoint remains retained until process exit because its static initializers may have started work; there is no automatic unload or rollback. Assertions for other program invariants remain unchanged. `update()` return values remain ignored. Keep the runtime plugin selection complete and check startup results in callers that manage their own loop. diff --git a/docs/guides/modules_CN.md b/docs/guides/modules_CN.md index 3c32ac86a..9c003d3bf 100644 --- a/docs/guides/modules_CN.md +++ b/docs/guides/modules_CN.md @@ -106,7 +106,7 @@ private: `init()`、`post_init()`、`check_config()` 或 `pre_update()` 首次返回 `false` 时,管理器停止该阶段的剩余模块及后续启动阶段,`plugin_manager::start()` 返回 `false`。同一阶段内每个回调至多执行一次。异常保持原样向外传播,也会停止剩余启动调用。失败时,`get_startup_failure()` 保留当前的 `phase: module` 上下文;库加载及其入口调用使用 `load_plugin: plugin`。成功启动后上下文为空,读取时必须位于管理器所属线程。已经完成的阶段不会自动回滚。 -应用启动器输出保留的上下文及可用异常信息,并在进入更新循环或启动 Windows 命令线程前返回退出码 `1`。这个失败路径不会调用 `pre_shut()` 或 `shut()` 清理部分启动资源,也不会在同一进程中重试;部署工具应启动新进程。库加载及入口符号检查中原有的断言行为保持不变。`update()` 的返回值仍被忽略。部署时应完整配置依赖插件;自行管理循环的调用方需要检查启动结果。 +应用启动器输出保留的上下文及可用异常信息,并在进入更新循环或启动 Windows 命令线程前返回退出码 `1`。这个失败路径不会调用 `pre_shut()` 或 `shut()` 清理部分启动资源,也不会在同一进程中重试;部署工具应启动新进程。缺少库文件或 `DllEntryPlugin` 入口时,管理器返回 false,并保留 `load_plugin: ` 上下文。已加载但缺少入口的库保留至进程退出,因为其静态初始化可能已启动工作;管理器不会自动卸载或回滚。其他程序不变量的断言保持不变。`update()` 的返回值仍被忽略。部署时应完整配置依赖插件;自行管理循环的调用方需要检查启动结果。 diff --git a/include/ark/plugin_manager.hpp b/include/ark/plugin_manager.hpp index 272bcd1ff..20e516bc6 100644 --- a/include/ark/plugin_manager.hpp +++ b/include/ark/plugin_manager.hpp @@ -453,8 +453,10 @@ class plugin_manager final : private noncopyable { phase_guard installing(*this, call_phase::installing); startup_failure_ = "load_plugin: " + iter.first; - bool ret = load_plugin_library(iter.first); - ARK_ASSERT_RET_VAL(ret, false); + if (!load_plugin_library(iter.first)) + { + return false; + } } // initialize all modules @@ -606,7 +608,13 @@ class plugin_manager final : private noncopyable { plugin_libs_.insert(std::make_pair(plugin_name, library)); auto func = reinterpret_cast(library->get_symbol("DllEntryPlugin")); - ARK_ASSERT_RET_VAL(func != nullptr, false); + if (func == nullptr) + { + ARK_LOG_ERROR("Shared library[{}] has no DllEntryPlugin entrypoint", library->get_name()); + // Keep an already loaded handle until process exit. Static + // initializers may have started work before symbol validation. + return false; + } func(this, plugin_name, logger::instance()); return true; @@ -619,10 +627,10 @@ class plugin_manager final : private noncopyable auto error = dlerror(); #endif ARK_LOG_ERROR("Load shared library[{}] failed, ErrorNo[{}]", library->get_name(), error); - ARK_ASSERT_RET_VAL(0, false); + // Native loading failed, so this object owns no loaded handle. + ARK_DELETE(library); + return false; } - - return true; } auto unload_plugin_library(const std::string& plugin_name) -> bool { diff --git a/tests/app/missing_entry_plugin.cpp b/tests/app/missing_entry_plugin.cpp new file mode 100644 index 000000000..0ba91718e --- /dev/null +++ b/tests/app/missing_entry_plugin.cpp @@ -0,0 +1,26 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include + +#include + +namespace ark { +class plugin_manager; +} + +namespace { + +struct library_probe +{ + library_probe() { + std::cerr << "app-startup-test:library_loaded\n"; + } +}; + +library_probe loaded_library; + +} // namespace + +// A loadable library deliberately omitting DllEntryPlugin. +ARK_EXPORT_FUNC void DllExitPlugin(ark::plugin_manager*) { + std::cerr << "app-startup-test:uninstall\n"; +} diff --git a/tests/app/startup_failures.cmake b/tests/app/startup_failures.cmake index db88ada12..b92b095b3 100644 --- a/tests/app/startup_failures.cmake +++ b/tests/app/startup_failures.cmake @@ -16,20 +16,27 @@ string(REPLACE "\"" "\\\"" toml_plugin_directory "${plugin_directory}") set(plugin_name ark_app_startup_failure_plugin) foreach(mode false:init false:post_init false:check_config false:pre_update - throw:init throw:post_init throw:check_config throw:pre_update unknown:init throw:load_plugin) + throw:init throw:post_init throw:check_config throw:pre_update unknown:init throw:load_plugin missing_library:load_plugin missing_entry:load_plugin) string(REPLACE ":" ";" parts "${mode}") list(GET parts 0 behavior) list(GET parts 1 failed_phase) + if(behavior STREQUAL "missing_library") + set(case_plugin ark_app_missing_library_plugin) + elseif(behavior STREQUAL "missing_entry") + set(case_plugin ark_app_missing_entry_plugin) + else() + set(case_plugin "${plugin_name}") + endif() foreach(format toml app.conf) set(manifest "${RUNTIME_DIR}/${behavior}-${failed_phase}.${format}") if(format STREQUAL "toml") file(WRITE "${manifest}" "[application]\nplugin_library_dir = \"${toml_plugin_directory}\"\n" - "[[plugins]]\nname = \"${plugin_name}\"\nconfig = \"${mode}\"\n") + "[[plugins]]\nname = \"${case_plugin}\"\nconfig = \"${mode}\"\n") else() file(WRITE "${manifest}" "" - "\n") + "\n") endif() execute_process( COMMAND "${PROGRAM}" --busid=1.0.1.1 --name=startup-error-test @@ -47,7 +54,7 @@ foreach(mode false:init false:post_init false:check_config false:pre_update message(FATAL_ERROR "${failed_phase}/${format}: expected normal exit 1, got ${result}\n${output}\n${errors}") endif() if(failed_phase STREQUAL "load_plugin") - set(owner "${plugin_name}") + set(owner "${case_plugin}") else() set(owner app_startup_failure_module) endif() @@ -71,8 +78,12 @@ foreach(mode false:init false:post_init false:check_config false:pre_update elseif(behavior STREQUAL "unknown" AND NOT errors MATCHES "unknown exception") message(FATAL_ERROR "${mode}/${format}: unknown exception was not identified\n${errors}") endif() - set(expected) - if(failed_phase STREQUAL "load_plugin") + set(expected "") + if(behavior STREQUAL "missing_entry") + list(APPEND expected "app-startup-test:library_loaded") + elseif(behavior STREQUAL "missing_library") + # No module or library initializer can run for a missing file. + elseif(failed_phase STREQUAL "load_plugin") list(APPEND expected "app-startup-test:load_plugin") else() foreach(phase init post_init check_config pre_update) @@ -83,7 +94,7 @@ foreach(mode false:init false:post_init false:check_config false:pre_update endforeach() endif() string(REGEX MATCHALL "app-startup-test:[a-z_]+" observed "${errors}") - if(NOT observed STREQUAL expected) + if(NOT "${observed}" STREQUAL "${expected}") message(FATAL_ERROR "${failed_phase}/${format}: unexpected lifecycle calls\nexpected=${expected}\nobserved=${observed}\n${errors}") endif() message(STATUS "${mode}/${format}: diagnostic and normal exit 1; no later phase, update, shutdown or uninstall call") diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index 96db53914..adbd2f00c 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -113,13 +113,16 @@ if(ARK_BUILD_CONFIG) add_library(ark_app_startup_failure_plugin SHARED ../app/startup_failure_plugin.cpp) set_property(TARGET ark_app_startup_failure_plugin PROPERTY LINK_LIBRARIES ark::core) set_target_properties(ark_app_startup_failure_plugin PROPERTIES PREFIX "") + add_library(ark_app_missing_entry_plugin SHARED ../app/missing_entry_plugin.cpp) + set_property(TARGET ark_app_missing_entry_plugin PROPERTY LINK_LIBRARIES ark::core) + set_target_properties(ark_app_missing_entry_plugin PROPERTIES PREFIX "") add_custom_target(check-app-startup-failures COMMAND ${CMAKE_COMMAND} "-DPROGRAM=$" "-DPLUGIN_DIR=$" "-DSOURCE_ROOT=${PROJECT_SOURCE_DIR}" "-DRUNTIME_DIR=${CMAKE_CURRENT_BINARY_DIR}/startup failure runtime" -P "${PROJECT_SOURCE_DIR}/tests/app/startup_failures.cmake" - DEPENDS app ark_app_startup_failure_plugin + DEPENDS app ark_app_startup_failure_plugin ark_app_missing_entry_plugin USES_TERMINAL) add_dependencies(check-core check-app-startup-failures) diff --git a/tests/core/README.md b/tests/core/README.md index ddd335b64..f9c932594 100644 --- a/tests/core/README.md +++ b/tests/core/README.md @@ -31,7 +31,7 @@ launcher against missing, malformed and mistyped manifests. It requires normal exit code 1 with a manifest diagnostic, including on Windows, and is included in `check-core`. -`check-app-startup-failures` uses the real launcher and a test DSO for all four startup phases, each returning false or throwing, with both TOML and XML manifests. It also checks a non-standard exception and a throwing plugin entrypoint. All 20 children must report one exact phase/module-or-plugin diagnostic, preserve available exception details, and exit normally with code 1 without later startup calls, update, normal shutdown or uninstall. Core tests check retained failure context, unchanged exception propagation, first failure across multiple modules and owner-thread access. These tests establish failure reporting and process exit, not partial-start resource cleanup or same-process recovery. +`check-app-startup-failures` uses the real launcher and a test DSO for all four startup phases, each returning false or throwing, with both TOML and XML manifests. It also checks a non-standard exception, a throwing plugin entrypoint, a missing library and a loadable DSO with no `DllEntryPlugin`. The missing-entry fixture confirms native library initialization occurred before failure. All 24 children must report one exact phase/module-or-plugin diagnostic, preserve available exception details, and exit normally with code 1 without later startup calls, update, normal shutdown or uninstall. Core tests check retained failure context, unchanged exception propagation, first failure across multiple modules and owner-thread access. These tests establish failure reporting and process exit, not partial-start resource cleanup or same-process recovery. `check-kernel-initialization` loads real metadata and static data from `resource/meta/`. It checks four kernel/config initialization orders with both diff --git a/tests/core/README_CN.md b/tests/core/README_CN.md index 61a75cbaa..f1336899e 100644 --- a/tests/core/README_CN.md +++ b/tests/core/README_CN.md @@ -27,7 +27,7 @@ cmake --build core-build --target check-core 字段类型错误的清单,要求正常返回退出码 1 并输出清单诊断。该入口覆盖 Windows, 并纳入 `check-core`。 -`check-app-startup-failures` 使用真实启动器和测试动态库,覆盖四个启动阶段各自返回 false 或抛出异常,并分别读取 TOML 和 XML 清单;还验证非标准异常和插件入口抛出异常。20 个子进程均须输出一次准确的阶段、模块或插件诊断,保留可用异常信息,以正常退出码 1 结束,且不执行后续启动调用、update、正常关闭或卸载。核心用例检查失败上下文、原始异常传播、多模块中的首次失败停止及所属线程访问。这些测试证明失败报告与进程退出,不证明部分启动资源清理或同进程恢复。 +`check-app-startup-failures` 使用真实启动器和测试动态库,覆盖四个启动阶段各自返回 false 或抛出异常,并分别读取 TOML 和 XML 清单;还验证非标准异常、插件入口抛出异常、库文件缺失,以及可加载但没有 `DllEntryPlugin` 的动态库。缺少入口的测试库会记录自身初始化,确认库已加载后才发生失败。24 个子进程均须输出一次准确的阶段、模块或插件诊断,保留可用异常信息,以正常退出码 1 结束,且不执行后续启动调用、update、正常关闭或卸载。核心用例检查失败上下文、原始异常传播、多模块中的首次失败停止及所属线程访问。这些测试证明失败报告与进程退出,不证明部分启动资源清理或同进程恢复。 `check-kernel-initialization` 从 `resource/meta/` 加载真实元数据和静态数据, 分别使用资源相对路径及以 `/` 开头的旧路径,验证内核与配置模块的四种初始化顺序,共八种组合。