diff --git a/docs/guides/applications.md b/docs/guides/applications.md index e74a9e7eb..6211edbd8 100644 --- a/docs/guides/applications.md +++ b/docs/guides/applications.md @@ -87,13 +87,15 @@ For Sample1, look for a log entry containing `sample1_module` and `PostInit`. Th On macOS/Linux, Ctrl+C or `SIGTERM` requests shutdown through the application's main loop. On Windows, enter `exit` in the application console. The launcher first stops managed top-level task admission and continues module updates plus the shared owner-thread poll until accepted tasks finish. Their services and bridges remain available so those tasks can complete later awaits. -After task completion and the active callback stack returns, the manager synchronously invokes shutdown hooks and unloading. When all hooks succeed, Sample1 logs `PreShut` and then `Shut`, and the launcher exits. A hook may block; the handler-count budget is not a shutdown-duration bound. +After task completion and the active callback stack returns, the manager waits for every module to report `ready_to_shut()`, then synchronously invokes shutdown hooks and unloading. A module must use its owner-frame readiness check to stop/drain native work and confirm native thread exit before allowing unload. When all hooks succeed, Sample1 logs `PreShut` and then `Shut`, and the launcher exits. A hook may block; the handler-count budget is not a shutdown-duration bound. -If a hook returns `false` or throws, the launcher reports its phase, module and available exception details once, keeps the process/resources alive, and does not continue unloading. There is no implicit timeout or cancellation fallback. This managed-task drain does not prove that arbitrary callbacks, native pool workers or background cleanup have finished; see [module task integration](tasks.md#shared-business-executor-for-modules). Sample1's logs alone are not a readiness or shutdown guarantee for every plugin combination. +If a shutdown hook returns `false` or throws, the launcher reports its phase, module and available exception details once, keeps the process/resources alive, and does not continue unloading. `plugin_manager::update()` returning `false` or throwing during drain also locks the launcher into retention; owner updates continue where possible, but later completion cannot resume unloading. + +`--shutdown_timeout_ms=N` configures a non-negative int64 wait in milliseconds; the default `0` waits indefinitely. A positive limit uses a monotonic clock and reports elapsed time, pending managed-task count and the waiting phase once. On expiry the process and resources stay alive, owner updates continue, and unloading remains disabled even if work completes later. This is a cooperative owner-loop wait limit: it cannot interrupt a blocking user `update()`, readiness check, shutdown hook or unload call. It is not a hard process-stop deadline, cancellation policy or successful shutdown. This managed-task drain does not prove that arbitrary callbacks, native pool workers or background cleanup have finished; see [module task integration](tasks.md#shared-business-executor-for-modules). Sample1's logs alone are not a readiness or shutdown guarantee for every plugin combination. diff --git a/docs/guides/applications_CN.md b/docs/guides/applications_CN.md index 3568f627d..29e45df6a 100644 --- a/docs/guides/applications_CN.md +++ b/docs/guides/applications_CN.md @@ -87,13 +87,15 @@ Sample1 的日志出现 `sample1_module` 和 `PostInit`,表示示例已经进 macOS/Linux 可使用 Ctrl+C 或 `SIGTERM`,由应用主循环处理停止请求。Windows 在应用控制台输入 `exit`。启动器先停止受理新的顶层业务任务,继续执行模块更新与共享执行器轮询,直到已受理任务完成。任务使用的服务和桥接继续可用,以便协程完成后续等待。 -任务完成且当前回调返回后,管理器同步执行关闭钩子与卸载。所有钩子成功时,Sample1 依次输出 `PreShut` 和 `Shut`,随后启动器退出。钩子可能阻塞;handler 数量预算不代表停机耗时上限。 +任务完成且当前回调返回后,管理器等待所有模块的 `ready_to_shut()` 返回就绪,再同步执行关闭钩子与卸载。模块应在 owner frame 的就绪检查中停止并排空原生工作,确认原生线程退出后才允许卸载。所有钩子成功时,Sample1 依次输出 `PreShut` 和 `Shut`,随后启动器退出。钩子可能阻塞;handler 数量预算不代表停机耗时上限。 -钩子返回 `false` 或抛出异常时,启动器输出一次阶段、模块及可用的异常信息,保留进程和资源,不再继续卸载。本实现没有隐含超时或自动取消的后备策略。业务任务排空不证明外部回调、原生连接池线程或后台清理已经完成,详见[模块任务集成](tasks_CN.md#模块共用业务执行器)。仅凭 Sample1 的日志,不能推断所有插件组合都具备相同的就绪条件或停止保证。 +关闭钩子返回 `false` 或抛出异常时,启动器输出一次阶段、模块及可用的异常信息,保留进程和资源,不再继续卸载。排空期间的 `plugin_manager::update()` 返回 `false` 或抛出异常,也会锁定资源保留状态;启动器尽可能继续 owner 更新,但后续完成不能恢复卸载。 + +`--shutdown_timeout_ms=N` 设置等待时限,单位为毫秒,接受非负 int64 整数;默认值 `0` 表示无限等待。正数使用单调时钟计时,超时后输出一次实际等待时间、待完成的业务任务数和等待阶段。进程与资源继续保留,owner 更新继续运行;即使工作随后完成,也不再卸载。这是 owner loop 的协作式等待时限,不能中断阻塞中的用户 `update()`、就绪检查、关闭钩子或卸载调用,不构成强制进程退出期限、自动取消策略或成功停机。业务任务排空不证明外部回调、原生连接池线程或后台清理已经完成,详见[模块任务集成](tasks_CN.md#模块共用业务执行器)。仅凭 Sample1 的日志,不能推断所有插件组合都具备相同的就绪条件或停止保证。 diff --git a/docs/guides/modules.md b/docs/guides/modules.md index 84bb820e2..298345e10 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..9c003d3bf 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 8d361ada4..2627328ca 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_; @@ -445,8 +451,11 @@ class plugin_manager final : private noncopyable for (auto const& iter : plugins_) { phase_guard installing(*this, call_phase::installing); - bool ret = load_plugin_library(iter.first); - ARK_ASSERT_RET_VAL(ret, false); + startup_failure_ = "load_plugin: " + iter.first; + if (!load_plugin_library(iter.first)) + { + return false; + } } // initialize all modules @@ -455,12 +464,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; } @@ -470,12 +481,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; } @@ -485,12 +498,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; } @@ -500,12 +515,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; } @@ -590,7 +607,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; @@ -603,10 +626,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 { @@ -681,6 +704,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 45a60d1ca..020647284 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -18,7 +18,10 @@ * */ +#include +#include #include +#include #include #include @@ -140,7 +143,7 @@ void create_back_thread() { } #endif // ARK_PLATFORM_WIN -bool parse_args(int argc, char* argv[]) { +bool parse_args(int argc, char* argv[], std::chrono::milliseconds& shutdown_timeout) { #if defined(ARK_PLATFORM_LINUX) || defined(ARK_PLATFORM_DARWIN) // ignore signals signal(SIGHUP, SIG_IGN); @@ -185,6 +188,10 @@ bool parse_args(int argc, char* argv[]) { args::ValueFlag log_conf(parser, "log config path", "Set application log configuration", {'l', "log_conf"}, "./conf/log.conf", args::Options::Single); + args::ValueFlag shutdown_wait(parser, "milliseconds", + "Retain process/resources if shutdown waits this long (0: unlimited)", {"shutdown_timeout_ms"}, "0", + args::Options::Single); + // start parse argument list try { parser.ParseCLI(argc, argv); } @@ -204,6 +211,16 @@ bool parse_args(int argc, char* argv[]) { return false; } + std::int64_t timeout_ms{}; + const auto& value = shutdown_wait.Get(); + const auto parsed = std::from_chars(value.data(), value.data() + value.size(), timeout_ms); + if (parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() || timeout_ms < 0) + { + std::cout << "Shutdown timeout must be a non-negative integer in milliseconds within int64 range" << std::endl; + return false; + } + shutdown_timeout = std::chrono::milliseconds(timeout_ms); + // Set bus id if (busid) { @@ -262,21 +279,44 @@ bool parse_args(int argc, char* argv[]) { return true; } -void main_loop() { +bool capture_update_exception(std::exception_ptr& error) noexcept { + try + { return plugin_manager::instance()->update(date_time::get_now_time()); } + catch (...) + { + error = std::current_exception(); + return false; + } +} + +bool main_loop(std::exception_ptr* error = nullptr) { #ifdef ARK_PLATFORM_WIN __try - { plugin_manager::instance()->update(date_time::get_now_time()); } - __except (application_crash_handler(GetExceptionInformation())) { - // Do nothing for now. + // Catch C++ exceptions before the outer SEH filter sees them. The + // shutdown owner rethrows only after this guarded frame returns. + return error ? capture_update_exception(*error) : plugin_manager::instance()->update(date_time::get_now_time()); } + __except (application_crash_handler(GetExceptionInformation())) + { return false; } #else - plugin_manager::instance()->update(date_time::get_now_time()); + return error ? capture_update_exception(*error) : plugin_manager::instance()->update(date_time::get_now_time()); #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[]) { - if (!parse_args(argc, argv)) + std::chrono::milliseconds shutdown_timeout{}; + if (!parse_args(argc, argv, shutdown_timeout)) { std::cout << "Application parameter is invalid, please check it..." << std::endl; return 0; @@ -294,10 +334,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; } @@ -322,11 +375,29 @@ int main(int argc, char* argv[]) { // Normal shutdown closes only managed top-level task admission. Accepted // tasks still need module updates and the shared owner executor to finish. - // No implicit deadline, cancellation or detach permits early process exit. + // A configured deadline latches resource retention; it never authorizes + // cancellation, unloading or process exit, even if work completes later. + const auto shutdown_started = std::chrono::steady_clock::now(); + bool shutdown_retained = false; bool shutdown_failure_reported = false; bool shutdown_update_error_reported = false; - while (!plugin_manager::instance()->stop(date_time::get_now_time())) + for (;;) { + const auto elapsed = + std::chrono::duration_cast(std::chrono::steady_clock::now() - shutdown_started); + if (!shutdown_retained && shutdown_timeout.count() > 0 && elapsed >= shutdown_timeout && + plugin_manager::instance()->get_shutdown_state() == plugin_manager::shutdown_state::draining) + { + shutdown_retained = true; + const auto pending = plugin_manager::instance()->pending_tasks(); + ARK_LOG_ERROR("Shutdown wait timed out after {} ms (limit={} ms, pending={}, phase={}); " + "process and resources are retained", + elapsed.count(), shutdown_timeout.count(), pending, pending ? "managed_tasks" : "ready_to_shut"); + } + if (!shutdown_retained && plugin_manager::instance()->stop(date_time::get_now_time())) + { + break; + } if (plugin_manager::instance()->get_shutdown_state() == plugin_manager::shutdown_state::failed) { if (!shutdown_failure_reported) @@ -356,21 +427,40 @@ int main(int argc, char* argv[]) { else { try - { main_loop(); } + { + std::exception_ptr update_error; + const auto updated = main_loop(&update_error); + if (update_error) + { + std::rethrow_exception(update_error); + } + if (!updated) + { + shutdown_retained = true; + if (!shutdown_update_error_reported) + { + ARK_LOG_ERROR( + "{}", "Shutdown update failed: update returned false; process and resources are retained"); + shutdown_update_error_reported = true; + } + } + } catch (const std::exception& error) { + shutdown_retained = true; if (!shutdown_update_error_reported) { - ARK_LOG_ERROR( - "Shutdown update failed: {}; resources are retained for the owner pump", error.what()); + ARK_LOG_ERROR("Shutdown update failed: {}; process and resources are retained", error.what()); shutdown_update_error_reported = true; } } catch (...) { + shutdown_retained = true; if (!shutdown_update_error_reported) { - ARK_LOG_ERROR("{}", "Shutdown update failed with an unknown exception; resources are retained"); + ARK_LOG_ERROR( + "{}", "Shutdown update failed with an unknown exception; process and resources are retained"); shutdown_update_error_reported = true; } } diff --git a/tests/app/business_runtime.cmake b/tests/app/business_runtime.cmake index 94168152a..a81b9832f 100644 --- a/tests/app/business_runtime.cmake +++ b/tests/app/business_runtime.cmake @@ -7,7 +7,7 @@ foreach(argument PROGRAM PLUGIN_DIR SOURCE_ROOT RUNTIME_DIR) endforeach() file(TO_CMAKE_PATH "${PLUGIN_DIR}" plugin_directory) -foreach(mode drain pre_shut_false shut_false shut_throw) +foreach(mode drain drain_deadline drain_max_deadline pre_shut_false shut_false shut_throw timeout_task timeout_native timeout_stalled update_throw update_throw_unknown) set(directory "${RUNTIME_DIR}/${mode}") file(MAKE_DIRECTORY "${directory}") file(REMOVE "${directory}/business-runtime.trace") @@ -15,7 +15,15 @@ foreach(mode drain pre_shut_false shut_false shut_throw) file(WRITE "${directory}/application.toml" "[application]\nplugin_library_dir = '${plugin_directory}/'\nplugin_config_dir = './'\n" "[[plugins]]\nname = 'ark_business_runtime_probe'\nconfig = '${mode}'\n") - if(mode STREQUAL "drain") + set(deadline_argument) + if(mode MATCHES "^timeout_") + set(deadline_argument --shutdown_timeout_ms=50) + elseif(mode STREQUAL "drain_max_deadline") + set(deadline_argument --shutdown_timeout_ms=9223372036854775807) + elseif(mode STREQUAL "drain_deadline") + set(deadline_argument --shutdown_timeout_ms=2000) + endif() + if(mode MATCHES "^drain") set(timeout 15) else() # An owned failing child must remain alive. This is a test observation @@ -25,7 +33,7 @@ foreach(mode drain pre_shut_false shut_false shut_throw) execute_process( COMMAND "${PROGRAM}" --busid=1.0.1.1 --name=business-runtime-test "--app_conf=${directory}/application.toml" - "--log_conf=${SOURCE_ROOT}/build/conf/log.conf" + "--log_conf=${SOURCE_ROOT}/build/conf/log.conf" ${deadline_argument} WORKING_DIRECTORY "${directory}" INPUT_FILE "${directory}/exit.txt" RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE errors TIMEOUT ${timeout}) @@ -36,7 +44,17 @@ foreach(mode drain pre_shut_false shut_false shut_throw) endif() file(STRINGS "${directory}/business-runtime.trace" events) set(previous -1) - foreach(event start-1 reply-1 received-1 start-2 reply-2 received-2 complete-begin callback-stop-pending complete-end pre-shut) + set(expected_events start-1 root-rejected) + if(mode MATCHES "^update_throw") + list(APPEND expected_events update-throw update-resumed) + endif() + if(NOT mode STREQUAL "timeout_stalled") + list(APPEND expected_events reply-1 received-1 start-2 reply-2 received-2 complete-begin callback-stop-pending complete-end owner-stop native-exit) + endif() + if(mode MATCHES "^drain" OR mode MATCHES "^(pre_shut_false|shut_false|shut_throw)$") + list(APPEND expected_events native-joined pre-shut) + endif() + foreach(event IN LISTS expected_events) list(FIND events "${event}" current) if(current LESS_EQUAL previous) message(FATAL_ERROR "${mode}: missing/out-of-order ${event}: ${events}\n${output}\n${errors}") @@ -49,13 +67,16 @@ foreach(mode drain pre_shut_false shut_false shut_throw) endif() set(previous ${current}) endforeach() - if("cancel" IN_LIST events OR "task-error" IN_LIST events) + if("cancel" IN_LIST events OR "task-error" IN_LIST events OR "unexpected-root" IN_LIST events) message(FATAL_ERROR "${mode}: accepted task was cancelled or failed: ${events}") endif() - if(mode STREQUAL "drain") + if(mode MATCHES "^drain") if(NOT "${result}" STREQUAL "0") message(FATAL_ERROR "Normal drain must exit 0, got ${result}\n${output}\n${errors}") endif() + if("${output}\n${errors}" MATCHES "Shutdown (failed at |wait timed out |update failed)") + message(FATAL_ERROR "${mode}: successful drain reported a shutdown failure") + endif() foreach(event shut uninstall destroy) list(FIND events "${event}" current) if(current LESS_EQUAL previous) @@ -63,7 +84,7 @@ foreach(mode drain pre_shut_false shut_false shut_throw) endif() set(previous ${current}) endforeach() - message(STATUS "Real launcher drain: two owning responses and callback retirement before shutdown, exit 0") + message(STATUS "${mode}: accepted task, callback retirement and native thread join precede unload, exit 0") else() if(NOT "${result}" MATCHES "[Tt]imeout") message(FATAL_ERROR "${mode}: expected retained process until test timeout, got ${result}") @@ -71,7 +92,33 @@ foreach(mode drain pre_shut_false shut_false shut_throw) if("uninstall" IN_LIST events OR "destroy" IN_LIST events) message(FATAL_ERROR "${mode}: failure incorrectly unloaded/destroyed its module: ${events}") endif() - if(mode STREQUAL "pre_shut_false" AND "shut" IN_LIST events) + if((mode MATCHES "^timeout_" OR mode MATCHES "^update_throw") AND ("pre-shut" IN_LIST events OR "shut" IN_LIST events OR "native-joined" IN_LIST events)) + message(FATAL_ERROR "${mode}: retained launcher resumed shutdown after its failure: ${events}") + endif() + if(mode MATCHES "^timeout_") + if(mode STREQUAL "timeout_native") + set(pending 0) + set(phase ready_to_shut) + else() + set(pending 1) + set(phase managed_tasks) + endif() + string(REGEX MATCH "Shutdown wait timed out after ([0-9]+) ms" timed_out "${output}\n${errors}") + if(NOT timed_out OR CMAKE_MATCH_1 LESS 50) + message(FATAL_ERROR "${mode}: deadline fired before its configured 50 ms wait") + endif() + if(NOT "${output}\n${errors}" MATCHES "Shutdown wait timed out after [0-9]+ ms.*limit=50 ms.*pending=${pending}.*phase=${phase}.*process and resources are retained") + message(FATAL_ERROR "${mode}: missing timeout wait/pending/phase/retention diagnostic\n${output}\n${errors}") + endif() + elseif(mode STREQUAL "update_throw_unknown") + if(NOT "${output}\n${errors}" MATCHES "Shutdown update failed with an unknown exception; process and resources are retained") + message(FATAL_ERROR "Unknown drain update exception lost its retained failure diagnostic") + endif() + elseif(mode STREQUAL "update_throw") + if(NOT "${output}\n${errors}" MATCHES "Shutdown update failed: test drain update exception; process and resources are retained") + message(FATAL_ERROR "Drain update exception lost its retained failure diagnostic") + endif() + elseif(mode STREQUAL "pre_shut_false" AND "shut" IN_LIST events) message(FATAL_ERROR "pre_shut failure incorrectly entered shut") endif() if(mode STREQUAL "pre_shut_false") @@ -79,10 +126,10 @@ foreach(mode drain pre_shut_false shut_false shut_throw) else() set(failed_phase shut) endif() - if(NOT "${output}\n${errors}" MATCHES "${failed_phase}: business-runtime-probe") + if(NOT mode MATCHES "^timeout_" AND NOT mode MATCHES "^update_throw" AND NOT "${output}\n${errors}" MATCHES "${failed_phase}: business-runtime-probe") message(FATAL_ERROR "${mode}: diagnostic must identify the failed module and phase") endif() - string(REGEX MATCHALL "Shutdown failed at " diagnostics "${output}\n${errors}") + string(REGEX MATCHALL "Shutdown (failed at |wait timed out |update failed)" diagnostics "${output}\n${errors}") list(LENGTH diagnostics count) if(NOT count EQUAL 1) message(FATAL_ERROR "${mode}: expected one retained-resource diagnostic, got ${count}\n${output}\n${errors}") @@ -93,3 +140,13 @@ foreach(mode drain pre_shut_false shut_false shut_throw) message(STATUS "${mode}: failure diagnosed once; process/resources retained until owned test timeout (not successful shutdown)") endif() endforeach() + +# Reject malformed, negative and out-of-range deadlines before loading a plugin. +foreach(value -1 invalid 50ms 9223372036854775808) + execute_process(COMMAND "${PROGRAM}" --busid=1.0.1.1 --name=invalid-deadline + --app_conf=must-not-load.toml "--shutdown_timeout_ms=${value}" + RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE errors TIMEOUT 5) + if(NOT "${output}\n${errors}" MATCHES "Application parameter is invalid" OR "${output}\n${errors}" MATCHES "Application manifest loading failed") + message(FATAL_ERROR "Invalid shutdown deadline ${value} was not rejected at argument parsing: ${result}\n${output}\n${errors}") + endif() +endforeach() diff --git a/tests/app/business_runtime_plugin.cpp b/tests/app/business_runtime_plugin.cpp index 1608191ed..10220471b 100644 --- a/tests/app/business_runtime_plugin.cpp +++ b/tests/app/business_runtime_plugin.cpp @@ -1,11 +1,15 @@ // Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include #include #include #include #include #include +#include #include #include +#include #include #include @@ -13,7 +17,10 @@ namespace { +std::mutex trace_mutex; + void trace(const std::string& event) { + const std::lock_guard lock(trace_mutex); std::ofstream output("business-runtime.trace", std::ios::app); output << event << '\n'; } @@ -38,7 +45,15 @@ class probe_module final : public ark::module } bool init() override { - auto& tasks = manager_.create_task_group(*this, 1); + native_ = std::thread([this] { + while (!native_release_.load(std::memory_order_acquire)) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + trace("native-exit"); + native_exited_.store(true, std::memory_order_release); + }); + auto& tasks = manager_.create_task_group(*this, 2); return tasks.start(sequence(this), [this](std::exception_ptr error) { if (error) { @@ -57,6 +72,44 @@ class probe_module final : public ark::module } bool update() override { + const auto draining = manager_.get_shutdown_state() == ark::plugin_manager::shutdown_state::draining; + if (draining && !drain_observed_) + { + drain_observed_ = true; + drain_started_ = std::chrono::steady_clock::now(); + auto& tasks = manager_.get_tasks(*this); + if (tasks.start(rejected_root(), [](std::exception_ptr) {}) != 0) + { + throw std::runtime_error("Shutdown accepted a new root task"); + } + trace("root-rejected"); + if (mode_ == "update_throw_unknown") + { + trace("update-throw"); + throw 7; + } + if (mode_ == "update_throw") + { + trace("update-throw"); + throw std::runtime_error("test drain update exception"); + } + } + if ((mode_ == "update_throw" || mode_ == "update_throw_unknown") && draining && !update_resumed_) + { + update_resumed_ = true; + trace("update-resumed"); + } + const auto delayed = + draining && std::chrono::steady_clock::now() - drain_started_ < std::chrono::milliseconds(200); + if (completion_returned_ && !owner_stopped_) + { + owner_stopped_ = true; + trace("owner-stop"); + } + if (owner_stopped_ && ++native_updates_ > 2 && (mode_ != "timeout_native" || !delayed)) + { + native_release_.store(true, std::memory_order_release); + } if (pending_) { const auto state = manager_.get_shutdown_state(); @@ -68,7 +121,8 @@ class probe_module final : public ark::module std::raise(SIGTERM); #endif } - else if (state == ark::plugin_manager::shutdown_state::draining) + else if (state == ark::plugin_manager::shutdown_state::draining && mode_ != "timeout_stalled" && + (mode_ != "timeout_task" || !delayed)) { trace("reply-" + std::to_string(started_)); auto complete = std::move(pending_); @@ -79,9 +133,22 @@ class probe_module final : public ark::module return true; } + bool ready_to_shut() const override { + if (!owner_stopped_ || !native_exited_.load(std::memory_order_acquire)) + { + return false; + } + if (native_.joinable()) + { + native_.join(); + trace("native-joined"); + } + return true; + } + bool pre_shut() override { trace("pre-shut"); - if (!sequence_finished_ || !completion_returned_ || pending_ || bridge_.pending()) + if (!sequence_finished_ || !completion_returned_ || pending_ || bridge_.pending() || native_.joinable()) { throw std::runtime_error("Teardown preceded accepted business completion"); } @@ -110,6 +177,11 @@ class probe_module final : public ark::module return started_; } + static ark::task<> rejected_root() { + trace("unexpected-root"); + co_return; + } + static ark::task<> sequence(probe_module* self) { for (unsigned step = 1; step <= 2; ++step) { @@ -134,6 +206,14 @@ class probe_module final : public ark::module bool stop_requested_{}; bool sequence_finished_{}; bool completion_returned_{}; + bool drain_observed_{}; + bool update_resumed_{}; + bool owner_stopped_{}; + unsigned native_updates_{}; + std::chrono::steady_clock::time_point drain_started_; + std::atomic native_release_{}; + std::atomic native_exited_{}; + mutable std::thread native_; }; probe_module* active_module{}; 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_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..b92b095b3 100644 --- a/tests/app/startup_failures.cmake +++ b/tests/app/startup_failures.cmake @@ -15,17 +15,28 @@ 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 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}/${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 = \"${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 @@ -42,20 +53,50 @@ 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 "${case_plugin}") + else() + set(owner app_startup_failure_module) + endif() + 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() - 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(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() - endforeach() + if(NOT errors MATCHES "${exception}") + message(FATAL_ERROR "${mode}/${format}: exception details were lost\n${errors}") + endif() + 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(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) + 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) + 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/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 ebdd89360..c9da8a26e 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, 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 resource-relative paths and legacy paths beginning with `/`, for eight combinations. @@ -73,7 +75,6 @@ destruction. These tests require no database service. cmake --build core-build --target check-business-runtime ``` -The tests establish managed business-task drain, not native worker retirement or -generic dynamic-library unload safety. See [module task integration](../../docs/guides/tasks.md#shared-business-executor-for-modules). +The manager tests establish managed business-task drain. The launcher fixture below also checks one controlled native worker retirement barrier; neither establishes generic dynamic-library unload safety. See [module task integration](../../docs/guides/tasks.md#shared-business-executor-for-modules). -When applications and configuration are built, `check-app-business-runtime` loads a test-only native plugin in the real launcher. POSIX cases request SIGTERM; Windows supplies `exit` to the real command thread. The accepted task receives two controlled private-bridge results during draining and finishes its callback before shutdown/uninstall. Separate false/throwing-hook children must diagnose failure once, retain their module and remain alive until the owned test observation timeout. That timeout terminates only the test child and is not a successful shutdown or a production policy. No real database is involved. +When applications and configuration are built, `check-app-business-runtime` loads a test-only native plugin in the real launcher. POSIX cases request SIGTERM; Windows supplies `exit` to the real command thread. The accepted task receives two controlled private-bridge results during draining, rejects a new root task with spare group capacity, and finishes its callback before owner-frame stop, native worker exit/join and shutdown/uninstall. Default unlimited and positive-deadline successful drains both exit 0. Separate false/throwing-hook, standard/non-standard throwing-update, stalled-task and delayed task/native-worker children must diagnose failure once, retain their module and remain alive until the owned test observation timeout. Throwing-update traces require an observed throw followed by a resumed owner update and accepted completion; Windows must not route these handled C++ exceptions into the native crash dialog. Late completion after the configured application deadline must not resume unloading. Malformed, negative and overflowing CLI deadlines are rejected before manifest loading. The application deadline is cooperative and cannot interrupt a blocking user update/readiness/shutdown call. The owned test observation timeout terminates only the test child and is not a successful shutdown or a production policy. No real database is involved. diff --git a/tests/core/README_CN.md b/tests/core/README_CN.md index 749631bba..0d5bda96d 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 清单;还验证非标准异常、插件入口抛出异常、库文件缺失,以及可加载但没有 `DllEntryPlugin` 的动态库。缺少入口的测试库会记录自身初始化,确认库已加载后才发生失败。24 个子进程均须输出一次准确的阶段、模块或插件诊断,保留可用异常信息,以正常退出码 1 结束,且不执行后续启动调用、update、正常关闭或卸载。核心用例检查失败上下文、原始异常传播、多模块中的首次失败停止及所属线程访问。这些测试证明失败报告与进程退出,不证明部分启动资源清理或同进程恢复。 + `check-kernel-initialization` 从 `resource/meta/` 加载真实元数据和静态数据, 分别使用资源相对路径及以 `/` 开头的旧路径,验证内核与配置模块的四种初始化顺序,共八种组合。 @@ -62,7 +64,7 @@ cmake --build core-build --target check-logging cmake --build core-build --target check-business-runtime ``` -这些用例只证明受管理业务任务的排空,不证明原生工作线程退出、IO 清理完成或通用动态库卸载安全。 +管理器用例证明受管理业务任务的排空;下面的启动器用例还验证一个受控原生线程退出屏障,二者均不证明通用动态库卸载安全。 参见[模块任务集成](../../docs/guides/tasks_CN.md#模块共用业务执行器)。 -同时构建应用与配置读取器时,`check-app-business-runtime` 在真实启动器中加载测试专用原生插件。POSIX 用例请求 SIGTERM,Windows 向真实命令线程输入 `exit`。已受理任务在排空阶段收到两次受控私有桥接结果,最后完成回调返回后才进入关闭与卸载。另有关闭钩子返回失败或抛出异常的子进程,要求诊断只输出一次、模块仍保留,并存活至测试观察时限。该时限只终止测试自己创建的子进程,不表示成功停机或生产策略;测试不使用真实数据库。 +同时构建应用与配置读取器时,`check-app-business-runtime` 在真实启动器中加载测试专用原生插件。POSIX 用例请求 SIGTERM,Windows 向真实命令线程输入 `exit`。已受理任务在排空阶段收到两次受控私有桥接结果;任务组仍有空余容量时也拒绝新根任务。最后完成回调返回后,owner frame 停止原生工作并等待线程退出、join,随后才进入关闭与卸载。默认无限等待和配置正数时限的正常排空均须返回退出码 0。另有关闭钩子返回失败或抛出异常、update 抛出标准或非标准异常、任务停滞、任务或原生线程延迟完成的子进程,要求诊断只输出一次、模块仍保留,并存活至测试观察时限。更新异常轨迹必须包含抛出、owner 更新恢复和已受理任务完成;Windows 不能将这些已处理的 C++ 异常送入原生崩溃对话框。配置的应用等待时限到达后,后续完成不得恢复卸载;非法、负数和溢出的 CLI 时限必须在加载配置前拒绝。应用时限仅在 owner loop 返回时检查,不能中断阻塞中的用户更新、就绪检查或关闭调用。测试观察时限只终止测试自己创建的子进程,不表示成功停机或生产策略;测试不使用真实数据库。 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)); + } + } + } +}