// Step 2.2 (chassis): four-phase plugin lifecycle test. // // Exercises the new register_schemas (Phase B) ABI field and verifies: // * both register_schemas and init run when the plugin sets both; // register_schemas runs first, init second (ordering contract). // * plugins with a null register_schemas field skip Phase B and go // straight to init (pre-existing two-phase behavior preserved). // * during Phase B, the DB handle getters in services return nullptr. // Plugins that call them observe null and must handle it. // // The fake_plugin.cpp test helper is the plugin under test; it selects // between two static ProxySQL_PluginDescriptor layouts based on the // PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B env var, mirroring the descriptor- // version-selection pattern a real plugin would use. #include "ProxySQL_PluginManager.h" #include "ProxySQL_Plugin.h" #include "tap.h" #include #include #include #include #include #include #include #include #ifndef PROXYSQL_FAKE_PLUGIN_PATH #error "PROXYSQL_FAKE_PLUGIN_PATH must be defined" #endif namespace { char g_fake_admin_db = '\0'; char g_fake_config_db = '\0'; char g_fake_stats_db = '\0'; std::string g_log_path {}; void make_log_path() { char tpl[] = "/tmp/proxysql_plugin_lifecycle_log.XXXXXX"; int fd = mkstemp(tpl); if (fd >= 0) close(fd); g_log_path = tpl; setenv("PROXYSQL_FAKE_PLUGIN_LOG", g_log_path.c_str(), 1); } void clear_log() { if (g_log_path.empty()) return; // See plugin_manager_unit-t.cpp for the SonarCloud rationale. std::ofstream truncate_handle(g_log_path, std::ios::trunc); (void)truncate_handle; } std::string read_log() { if (g_log_path.empty()) return ""; std::ifstream s(g_log_path); return std::string((std::istreambuf_iterator(s)), std::istreambuf_iterator()); } void cleanup_log() { if (g_log_path.empty()) return; std::remove(g_log_path.c_str()); unsetenv("PROXYSQL_FAKE_PLUGIN_LOG"); } bool contains_in_order(const std::string& haystack, const char* first, const char* second) { size_t pos_first = haystack.find(first); if (pos_first == std::string::npos) return false; size_t pos_second = haystack.find(second, pos_first + 1); return pos_second != std::string::npos; } } // namespace // Symbols the plugin manager pulls from the core. In this test harness we // hand it fake, non-null pointers so init() sees "live" handles -- which // is what matters for the contrast against Phase B's nullptrs. SQLite3DB* proxysql_plugin_get_admindb() { return reinterpret_cast(&g_fake_admin_db); } SQLite3DB* proxysql_plugin_get_configdb() { return reinterpret_cast(&g_fake_config_db); } SQLite3DB* proxysql_plugin_get_statsdb() { return reinterpret_cast(&g_fake_stats_db); } // Case 1: plugin sets both register_schemas AND init. Both must fire. // register_schemas must fire strictly before init (the lifecycle contract // that lets plugins count on admin module materialization between them). static void test_phase_b_and_init_both_fire() { setenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B", "1", 1); clear_log(); std::unique_ptr mgr; std::vector paths { PROXYSQL_FAKE_PLUGIN_PATH }; std::string err; ok(proxysql_load_configured_plugins(mgr, paths, err) && proxysql_init_configured_plugins(mgr.get(), err), "load-then-register_schemas-then-init succeeds (err='%s')", err.c_str()); const std::string log = read_log(); ok(log.find("fake_plugin:phase_b") != std::string::npos, "register_schemas callback fired (log contains phase_b marker)"); ok(log.find("fake_plugin:init") != std::string::npos, "init callback fired (log contains init marker)"); ok(contains_in_order(log, "fake_plugin:phase_b", "fake_plugin:init"), "register_schemas ran strictly before init"); (void)proxysql_stop_configured_plugins(mgr, err); unsetenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B"); } // Case 2: plugin sets only init (register_schemas field is null). // Phase B is skipped; init still runs. This is the compat path for // plugins built against the pre-2.2 descriptor layout. static void test_only_init_skips_phase_b() { // ENABLE_PHASE_B unset -> descriptor returned omits register_schemas. unsetenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B"); clear_log(); std::unique_ptr mgr; std::vector paths { PROXYSQL_FAKE_PLUGIN_PATH }; std::string err; ok(proxysql_load_configured_plugins(mgr, paths, err) && proxysql_init_configured_plugins(mgr.get(), err), "load+init succeeds with no register_schemas set (err='%s')", err.c_str()); const std::string log = read_log(); ok(log.find("fake_plugin:phase_b") == std::string::npos, "register_schemas NOT invoked when descriptor field is null"); ok(log.find("fake_plugin:init") != std::string::npos, "init still fires on the legacy two-phase path"); (void)proxysql_stop_configured_plugins(mgr, err); } // Case 3: register_schemas tries to call DB handle getters. // // Contract: during Phase B the services struct passed to the plugin MUST // have the DB-handle getters wired to non-null stub functions that return // nullptr. Two regressions this test has to catch: // (a) loader passes the live `services_` (get_admindb() returns the // real, non-null admin DB) // (b) loader sets services_phase_b_.get_admindb = nullptr (plugins that // call the getter unconditionally would crash) // // The fake plugin emits one of three markers depending on which state it // observes; the correct marker is phase_b_handles_null. This test only // passes when the marker is present AND the two failure markers are // absent. The harness itself returns non-null fakes from // proxysql_plugin_get_admindb(); if the loader mistakenly used the live // services, the fake plugin would log phase_b_handles_live. static void test_phase_b_db_handles_are_null() { setenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B", "1", 1); setenv("PROXYSQL_FAKE_PLUGIN_PHASE_B_TOUCH_HANDLES", "1", 1); clear_log(); std::unique_ptr mgr; std::vector paths { PROXYSQL_FAKE_PLUGIN_PATH }; std::string err; ok(proxysql_load_configured_plugins(mgr, paths, err) && proxysql_init_configured_plugins(mgr.get(), err), "load succeeds even though plugin peeked at DB handles in Phase B"); const std::string log = read_log(); ok(log.find("fake_plugin:phase_b_handles_null") != std::string::npos, "DB handle getters returned nullptr during register_schemas (log='%s')", log.c_str()); ok(log.find("fake_plugin:phase_b_handles_live") == std::string::npos, "DB handles were NOT live during Phase B (contract)"); ok(log.find("fake_plugin:phase_b_getter_null") == std::string::npos, "Phase-B getters are non-null stubs, not nullptr pointers (contract)"); (void)proxysql_stop_configured_plugins(mgr, err); unsetenv("PROXYSQL_FAKE_PLUGIN_PHASE_B_TOUCH_HANDLES"); unsetenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B"); } // Case 4: a failing register_schemas aborts the load and init() is NOT // called. Verifies the loader treats Phase-B failure as a hard error. static void test_phase_b_failure_aborts_init() { setenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B", "1", 1); setenv("PROXYSQL_FAKE_PLUGIN_PHASE_B_FAIL", "1", 1); clear_log(); std::unique_ptr mgr; std::vector paths { PROXYSQL_FAKE_PLUGIN_PATH }; std::string err; ok(!proxysql_load_configured_plugins(mgr, paths, err), "load fails when register_schemas returns false"); ok(!err.empty() && err.find("register_schemas") != std::string::npos, "error message names the failing phase (err='%s')", err.c_str()); const std::string log = read_log(); ok(log.find("fake_plugin:phase_b_fail") != std::string::npos, "register_schemas actually ran and logged its failure"); ok(log.find("fake_plugin:init") == std::string::npos, "init was NOT called after register_schemas failed"); unsetenv("PROXYSQL_FAKE_PLUGIN_PHASE_B_FAIL"); unsetenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B"); } // Case 4b: register_schemas registers a table, then returns false. // The loader MUST roll back the partial registration so a subsequent // retry (with the bug fixed) doesn't trip on a duplicate table // registration. This is the contract that keeps reload-after-failure // viable: the registry is transactional per-plugin. static void test_phase_b_partial_failure_rolls_back() { setenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B", "1", 1); setenv("PROXYSQL_FAKE_PLUGIN_PHASE_B_PARTIAL_THEN_FAIL", "1", 1); clear_log(); std::unique_ptr mgr1; std::vector paths { PROXYSQL_FAKE_PLUGIN_PATH }; std::string err; ok(!proxysql_load_configured_plugins(mgr1, paths, err), "first load fails when register_schemas returns false after partial registration"); ok(read_log().find("fake_plugin:phase_b_partial_then_fail") != std::string::npos, "plugin actually registered a table before returning false"); // Retry with the toggle cleared. If the loader didn't roll back // the partial registration, we'd expect either a duplicate-table // error or a dirty registry. With rollback, load+init succeeds. unsetenv("PROXYSQL_FAKE_PLUGIN_PHASE_B_PARTIAL_THEN_FAIL"); clear_log(); std::unique_ptr mgr2; std::string err2; ok(proxysql_load_configured_plugins(mgr2, paths, err2) && proxysql_init_configured_plugins(mgr2.get(), err2), "retry succeeds — partial registration from the failed attempt was rolled back (err='%s')", err2.c_str()); (void)proxysql_stop_configured_plugins(mgr2, err2); unsetenv("PROXYSQL_FAKE_PLUGIN_ENABLE_PHASE_B"); } // Case 5: init() succeeds but start() fails. stop() MUST still be // called for teardown symmetry — anything init() allocated would otherwise // leak. This is the "init pairs with stop" contract. static void test_stop_runs_when_start_fails() { setenv("PROXYSQL_FAKE_PLUGIN_START_FAIL", "1", 1); clear_log(); std::unique_ptr mgr; std::vector paths { PROXYSQL_FAKE_PLUGIN_PATH }; std::string err; ok(proxysql_load_configured_plugins(mgr, paths, err) && proxysql_init_configured_plugins(mgr.get(), err), "load + init succeed on the plugin whose start will later fail"); ok(!proxysql_start_configured_plugins(mgr.get(), err), "start fails when plugin's start() returns false"); (void)proxysql_stop_configured_plugins(mgr, err); const std::string log = read_log(); ok(log.find("fake_plugin:init") != std::string::npos, "init did run (necessary precondition for the stop contract)"); ok(log.find("fake_plugin:start_fail") != std::string::npos, "start_fail marker confirms start() was called and returned false"); ok(log.find("fake_plugin:stop") != std::string::npos, "stop() was called for init-success/start-fail plugin (teardown symmetry)"); unsetenv("PROXYSQL_FAKE_PLUGIN_START_FAIL"); } // Case 6: plugin returns a descriptor with an unknown abi_version. The // loader MUST refuse to load such a plugin rather than read past the end // of its own (compiled-against) struct definition. This is the test that // keeps the tail-append pattern honest across plugin/core ABI skew. static void test_bogus_abi_version_rejected() { setenv("PROXYSQL_FAKE_PLUGIN_FORCE_BOGUS_ABI", "1", 1); clear_log(); std::unique_ptr mgr; std::vector paths { PROXYSQL_FAKE_PLUGIN_PATH }; std::string err; ok(!proxysql_load_configured_plugins(mgr, paths, err), "load fails when plugin declares an unsupported ABI version"); ok(!err.empty() && err.find("ABI") != std::string::npos, "error message names the ABI mismatch (err='%s')", err.c_str()); const std::string log = read_log(); ok(log.find("fake_plugin:init") == std::string::npos, "init was NOT called on a plugin rejected by the ABI check"); unsetenv("PROXYSQL_FAKE_PLUGIN_FORCE_BOGUS_ABI"); } int main() { plan(26); make_log_path(); test_phase_b_and_init_both_fire(); test_only_init_skips_phase_b(); test_phase_b_db_handles_are_null(); test_phase_b_failure_aborts_init(); test_phase_b_partial_failure_rolls_back(); test_stop_runs_when_start_fails(); test_bogus_abi_version_rejected(); cleanup_log(); return exit_status(); }