fix(plugin-chassis): address deep-review findings

Follow-up to cd48c5613 (PROXYSQL40 gating commit).  Multiple reviewers
reported issues ranging from descriptor-layout UB to test false-positives;
this commit resolves all of them.

Showstoppers fixed:

* plugins/mysqlx/Makefile: PROXYSQL40 (and all feature-tier flags) are
  now propagated from the top-level Makefile and converted to -DPROXYSQL40
  in CXXFLAGS.  Before this, PROXYSQL40=1 built a v4 core that dlopen'd a
  6-field v3 mysqlx descriptor, and the loader then read past the end of
  the plugin's static struct.  Top-level Makefile's four plugins/mysqlx
  recursive-make invocations now pass PROXYSQL40=$(PROXYSQL40); the
  test/tap/tests/unit mysqlx_plugin_build rule does the same.

* ABI version gating (S2/S3): add PROXYSQL_PLUGIN_ABI_VERSION macro to
  ProxySQL_Plugin.h (1 without PROXYSQL40, 2 with).  Plugins set
  descriptor.abi_version from this macro.  Loader rejects
  abi_version < 1 or > PROXYSQL_PLUGIN_ABI_VERSION_MAX.  The
  register_schemas field is only read when abi_version >= 2 -- a v1
  plugin's 6-field descriptor is no longer dereferenced past its own
  layout even when loaded by a v4 core.  ScopedRegistryTarget RAII guard
  replaces the inline g_registry_target = this / = nullptr pairs so
  exceptions from plugin callbacks can't leave the globals dirty.

* Admin_Handler TOCTOU: proxysql_resolve_configured_plugin_admin_alias
  now returns std::string by value instead of const char*.  The previous
  signature borrowed a c_str() from inside commands_ under the plugin
  manager lock; callers then released the lock before dispatch, and a
  concurrent reload between the two calls could dangle the pointer.
  Returning by value copies the canonical SQL out while the lock is still
  held.

* test_phase_b_db_handles_are_null was a false-positive trap: it logged
  "null" whenever the getter pointer was null OR returned null, so a
  regression to get_admindb = nullptr would have silently passed.  The
  fake plugin now emits three distinct markers (phase_b_getter_null vs
  phase_b_handles_null vs phase_b_handles_live); the test asserts the
  exact contract (non-null function pointer that returns nullptr).

* Snapshot stubs contract: the header claimed Phase D services are
  "every field LIVE", but get_mysql_users_snapshot et al. point to a
  nullptr-returning stub in every phase.  Header now explicitly
  documents that snapshot getters are not yet implemented and plugins
  MUST treat null return as "not available" across every phase.

* stop() teardown symmetry: stop_all() previously skipped plugins that
  succeeded init() but failed start().  Resources allocated in init
  would leak.  Now stop_all() runs stop() for every plugin where
  initialized=true, irrespective of started.  proxysql_plugin_stop_cb's
  header docstring documents this: stop pairs with init, not start.

* register_schemas partial-failure rollback: if a plugin's
  register_schemas registered some tables/commands and then returned
  false, the registry kept the partial registrations.  A retry (same
  plugin, bug fixed) would then duplicate-fail.  Both
  invoke_register_schemas_phase and init_all now snapshot the
  registration counts before invoking the plugin callback and truncate
  back on failure.

Tightened / documented:

* alias normalization delta (PROXYSQL40 collapses whitespace and trims
  trailing ';', !PROXYSQL40 is length-strict) is documented in
  canonicalize_plugin_command's docstring as an intentional UX
  improvement for the chassis tier, not a regression.

* init-after-publish ordering invariant documented at both the publish
  site (proxysql_load_configured_plugins Phase-B branch) and at
  proxysql_init_configured_plugins.  Phase D writes to commands_ /
  mysql_query_hook_ / pgsql_query_hook_ on the already-published
  manager; safe today because ProxySQL_Main_init_phase3___start_all
  runs strictly after Phase D, so no thread ever observes the manager
  during its Phase-D mutations.

* Phase-D failure path documented: treated as fatal startup error
  (exit(EXIT_FAILURE) in main.cpp).  Plugins that succeeded init() are
  still torn down via stop_all() during process teardown, now that stop
  pairs with init.  Runtime reload is out of scope for this iteration.

* Admin_Bootstrap.cpp materialize_plugin_tables(): removed duplicate
  ATTACH DATABASE calls for "disk" and "stats".  init_sqlite3db() in
  the same startup flow attaches them first; a duplicate ATTACH errors
  out in SQLite.

* test/tap/tests/unit/Makefile PROXYSQL31 autodetection: previously
  used the same probe symbol (MySQLFFTO) as PSQLFFTO, which collapsed
  PROXYSQL31 onto PROXYSQLFFTO.  Now PROXYSQL31 is set when either
  PROXYSQLFFTO or PROXYSQLTSDB is detected (matching the top-level
  cascade's PROXYSQL31 => FFTO && TSDB).

Tests added (all under PROXYSQL40 only):

* plugin_lifecycle_unit-t:
  - test_phase_b_partial_failure_rolls_back: plugin registers a table
    then returns false; rollback verified by retrying the same plugin
    with the failure toggle cleared -- succeeds.
  - test_stop_runs_when_start_fails: exercises the init-success /
    start-failure path and asserts stop() marker in log.
  - test_bogus_abi_version_rejected: loads a descriptor with
    abi_version=99; loader rejects with "unsupported plugin ABI
    version" and init is not called.
  - plan(26), up from plan(14).

* plugin_dispatch_unit-t:
  - test_cross_plugin_command_collision: loads both fake_plugin and
    fake_plugin2 as real .so's, both registering the same admin-command
    SQL; init fails with a message naming the collision.  Complements
    the same-manager API-level collision test already present.
  - plan(52), up from plan(50).

* plugin_query_hook_unit-t:
  - test_embedded_nul_in_query_text: dispatch with an embedded NUL in
    query_text asserts the full length_len byte range reaches the hook
    intact (not strlen-truncated).
  - test_reentrant_dispatch_across_protocols: mysql-hook callback
    re-enters dispatch_query_hook(pgsql).  Asserts no deadlock and
    correct inner-hook invocation count.
  - plan(46), up from plan(41).

Total plugin unit tests: 48 (config) + 52 (dispatch) + 26 (lifecycle) +
41 (prometheus) + 46 (query_hook) = 213 under PROXYSQL40;
47 + 32 = 79 under v3.1 non-chassis.
ProtocolX
Rene Cannao 1 month ago
parent cd48c5613e
commit ab9d5a1036

@ -281,7 +281,7 @@ build_lib_legacy: build_deps_legacy
.PHONY: build_src_legacy
build_src_legacy: build_lib_legacy
cd src && OPTZ="${O2} -ggdb" CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O2} -ggdb" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O2} -ggdb" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
.PHONY: build_deps_debug_legacy
build_deps_debug_legacy:
@ -294,7 +294,7 @@ build_lib_debug_legacy: build_deps_debug_legacy
.PHONY: build_src_debug_legacy
build_src_debug_legacy: build_lib_debug_legacy
cd src && OPTZ="${O0} -ggdb -DDEBUG" CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
#--
.PHONY: build_src_testaurora
@ -405,12 +405,12 @@ build_lib_debug_default: build_deps_debug_default
.PHONY: build_src_default
build_src_default: build_lib_default
cd src && OPTZ="${O2} -ggdb" PROXYSQLCLICKHOUSE=1 PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O2} -ggdb" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O2} -ggdb" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
.PHONY: build_src_debug_default
build_src_debug_default: build_lib_debug_default
cd src && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLCLICKHOUSE=1 PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
cd plugins/mysqlx && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE}
### packaging targets

@ -10,6 +10,27 @@ class SQLite3_result;
namespace prometheus { class Registry; }
#endif /* PROXYSQL40 */
// Descriptor ABI version the plugin was compiled for. Plugins set
// `abi_version` on their ProxySQL_PluginDescriptor literal to this macro
// so the core loader can detect layout skew between plugin and loader
// builds.
//
// ABI 1: original 6-field descriptor (name, abi_version, init, start,
// stop, status_json). Pre-chassis build.
// ABI 2: appends `register_schemas` (four-phase lifecycle, PROXYSQL40).
//
// A v3 core (built without PROXYSQL40) only understands ABI 1 and MUST
// reject ABI>=2 plugins — it would read past the end of its own struct
// definition. A v4 core accepts ABI 1 plugins by treating the
// register_schemas field as if null (never dereferenced on ABI 1).
#ifdef PROXYSQL40
#define PROXYSQL_PLUGIN_ABI_VERSION 2u
#define PROXYSQL_PLUGIN_ABI_VERSION_MAX 2u
#else
#define PROXYSQL_PLUGIN_ABI_VERSION 1u
#define PROXYSQL_PLUGIN_ABI_VERSION_MAX 1u
#endif
enum class ProxySQL_PluginDBKind : uint8_t {
admin_db = 0,
config_db = 1,
@ -159,12 +180,22 @@ using proxysql_plugin_get_prometheus_registry_cb =
// has not yet materialized the schema). Plugins using this callback
// MUST NOT touch DB handles here; save that work for init().
// - register_query_hook: RETURNS false (not yet wired).
// - snapshots: RETURN nullptr.
// * init (Phase D): every field LIVE. register_table/register_command
// remain valid during init as well (they append to the same registry).
// * start (Phase E) and beyond: get_*db, log_message, snapshots,
// - snapshots: RETURN nullptr (see below).
// * init (Phase D): register_*, log_message, get_*db and
// get_prometheus_registry are LIVE. Snapshot getters remain stubs —
// see the note below.
// * start (Phase E) and beyond: get_*db, log_message,
// get_prometheus_registry remain valid; register_* are no-ops (ignored
// with a warning — schemas must be declared before start).
//
// NOTE ON SNAPSHOT GETTERS (`get_mysql_users_snapshot`, etc.):
// These are currently wired to a stub that returns nullptr in every phase.
// The plan is to surface read-only SQLite3_result snapshots of the core's
// runtime config tables, but the backing plumbing (snapshot acquisition,
// lifetime, invalidation on reload) isn't implemented yet. Plugins MUST
// treat a nullptr return as "snapshot not available"; do not assume
// non-null just because you're in Phase D. When the feature lands, only
// the nullptr contract will change — the field signatures won't.
struct ProxySQL_PluginServices {
proxysql_plugin_register_table_cb register_table;
proxysql_plugin_register_command_cb register_command;
@ -197,6 +228,14 @@ using proxysql_plugin_init_cb =
using proxysql_plugin_start_cb =
bool (*)();
// stop() pairs with init() for teardown, not with start(). The loader
// guarantees that every plugin whose init() returned true will get stop()
// called exactly once -- even if its own start() returned false, and even
// if a later plugin's start() caused shutdown mid-startup. Plugins must
// therefore be able to tear down resources they allocated in init (config
// stores, caches, worker pools not yet spawned, ...) without having seen
// a matching start(). A plugin whose init() returned false never sees
// stop().
using proxysql_plugin_stop_cb =
bool (*)();

@ -47,11 +47,13 @@ public:
// (canonical, alias) pair are idempotent and return true.
bool register_command_alias(const char* canonical_sql, const char* alias_sql);
// Resolve an incoming admin-command spelling to its canonical form.
// Returns the canonical SQL (pointer owned by the manager, valid
// for its lifetime) if the query matches a registered command or
// any of its aliases; nullptr otherwise. Whitespace and case are
// normalized on both sides.
const char* resolve_alias_to_canonical(const std::string& sql) const;
// Returns an owned copy of the canonical SQL if the query matches a
// registered command or any of its aliases; an empty string
// otherwise. Whitespace and case are normalized on both sides.
// Returns by value (not const char*) so callers can release the
// manager lock before dispatching without risking pointer
// invalidation on concurrent reload.
std::string resolve_alias_to_canonical(const std::string& sql) const;
bool register_query_hook(ProxySQL_PluginProtocol proto, proxysql_plugin_query_hook_cb cb);
bool has_query_hook(ProxySQL_PluginProtocol proto) const;
bool dispatch_query_hook(ProxySQL_PluginProtocol proto,
@ -130,9 +132,10 @@ bool proxysql_dispatch_configured_plugin_query_hook(
bool proxysql_has_configured_plugin_query_hook(ProxySQL_PluginProtocol proto);
// Admin-side helper: consult the active plugin manager's command table and
// return the canonical spelling of `sql` if it's a registered command or
// alias, nullptr otherwise. The returned pointer is valid for the lifetime
// of the active manager (reload invalidates it).
const char* proxysql_resolve_configured_plugin_admin_alias(const std::string& sql);
// alias, or an empty string otherwise. Returns by value so callers can
// release the manager lock before dispatching without risking pointer
// invalidation on concurrent reload.
std::string proxysql_resolve_configured_plugin_admin_alias(const std::string& sql);
// Phase A + B of the four-phase lifecycle: dlopen() each module, read its
// descriptor, then call register_schemas() on plugins that opted in. On
// success, `manager` is populated AND installed as the active manager so

@ -1376,6 +1376,8 @@ void ProxySQL_Admin::materialize_plugin_tables() {
statsdb->execute(def.table_def);
}
__attach_db(admindb, configdb, (char *)"disk");
__attach_db(admindb, statsdb, (char *)"stats");
// admindb<->configdb("disk") and admindb<->statsdb("stats") are
// already attached by init_sqlite3db() above this function in the
// startup flow; duplicate ATTACH errors out in SQLite. Do NOT
// re-attach them here.
}

@ -4063,10 +4063,13 @@ void admin_session_handler(S* sess, void *_pa, PtrSize_t *pkt) {
// ladder.
{
std::string query_str(query_no_space, query_no_space_length);
const char* plugin_canonical = proxysql_resolve_configured_plugin_admin_alias(query_str);
if (plugin_canonical != nullptr) {
std::string plugin_canonical = proxysql_resolve_configured_plugin_admin_alias(query_str);
if (!plugin_canonical.empty()) {
// Hold a std::string by value (not a borrowed c_str()) so
// a concurrent plugin-manager reload between resolve and
// dispatch can't dangle the canonical-SQL pointer.
ProxySQL_Admin *SPA=(ProxySQL_Admin *)pa;
if (SPA->dispatch_plugin_admin_command(sess, plugin_canonical)) {
if (SPA->dispatch_plugin_admin_command(sess, plugin_canonical.c_str())) {
run_query = false;
goto __run_query;
}

@ -26,6 +26,27 @@ std::mutex g_active_plugin_manager_mutex {};
bool g_registry_registration_failed = false;
std::string g_registry_registration_error {};
// RAII guard that sets g_registry_target to `mgr` on construction and
// clears it on destruction. Also resets the registration-failure sticky
// bits. Used to bracket each plugin callback invocation during Phase B
// (register_schemas) and Phase D (init) so an exception thrown from the
// plugin can't leave the registry globals dirty and break the next
// phase's `assert(g_registry_target == nullptr)`.
struct ScopedRegistryTarget {
explicit ScopedRegistryTarget(ProxySQL_PluginManager* mgr) {
g_registry_target = mgr;
g_registry_registration_failed = false;
g_registry_registration_error.clear();
}
~ScopedRegistryTarget() {
g_registry_target = nullptr;
g_registry_registration_failed = false;
g_registry_registration_error.clear();
}
ScopedRegistryTarget(const ScopedRegistryTarget&) = delete;
ScopedRegistryTarget& operator=(const ScopedRegistryTarget&) = delete;
};
ProxySQL_PluginCommandResult ignored_test_command(const ProxySQL_PluginCommandContext&, const char*) {
return {0, 0, ""};
}
@ -181,6 +202,18 @@ bool sql_equals_ci(const std::string& lhs, const std::string& rhs) {
return strcasecmp(lhs.c_str(), rhs.c_str()) == 0;
}
// Normalize a plugin command for alias lookup: strip leading/trailing
// whitespace, strip a trailing ';', collapse internal whitespace runs to
// a single space.
//
// Intentional behavior delta from the pre-chassis v3 path
// (Admin_Handler::resolve_admin_alias_to_canonical), which requires an
// exact length match against the alias string via strncasecmp. Under the
// chassis, users can type "LOAD MYSQLX USERS TO RUN" (extra inner
// spaces) or "LOAD MYSQLX USERS TO RUN;" and have it resolve correctly;
// under the !PROXYSQL40 build only the exact spelling matches. Admin
// commands are low-volume and unambiguous; the looser matching is a
// strict UX improvement.
std::string canonicalize_plugin_command(const std::string& sql) {
size_t start = 0;
size_t end = sql.size();
@ -308,7 +341,14 @@ bool ProxySQL_PluginManager::load(const std::string &path, std::string &err) {
return false;
}
if (descriptor->abi_version != 1) {
// Reject plugins built for a newer ABI than this core understands: the
// plugin's descriptor struct would have more fields than ours, and
// dereferencing those fields would read past the end of our struct
// definition. The reverse direction (older ABI plugin, newer core) is
// safe via the tail-append pattern -- fields the plugin didn't define
// are never dereferenced (see handling of register_schemas below).
if (descriptor->abi_version < 1u ||
descriptor->abi_version > PROXYSQL_PLUGIN_ABI_VERSION_MAX) {
err = "unsupported plugin ABI version";
dlclose(handle);
return false;
@ -339,28 +379,55 @@ bool ProxySQL_PluginManager::invoke_register_schemas_phase(std::string &err) {
if (plugin.schemas_registered || plugin.stopped) {
continue;
}
if (plugin.descriptor == nullptr ||
plugin.descriptor->register_schemas == nullptr) {
// register_schemas only exists on ABI v2 descriptors. Reading it
// from a v1 plugin's static descriptor would be an out-of-bounds
// read -- v1 plugins allocate only the first 6 fields. Treat v1
// plugins as if they opted out of Phase B.
proxysql_plugin_register_schemas_cb register_schemas_cb = nullptr;
if (plugin.descriptor != nullptr && plugin.descriptor->abi_version >= 2u) {
register_schemas_cb = plugin.descriptor->register_schemas;
}
if (register_schemas_cb == nullptr) {
// Plugin opted out of Phase B -- the pre-existing two-phase
// path (init-only) still works: mark it as having completed
// Phase B so init_all doesn't get confused later.
plugin.schemas_registered = true;
continue;
}
g_registry_target = this;
g_registry_registration_failed = false;
g_registry_registration_error.clear();
const bool phase_b_ok = plugin.descriptor->register_schemas(&services_phase_b_);
const bool registration_failed = g_registry_registration_failed;
const std::string registration_error = g_registry_registration_error;
g_registry_registration_failed = false;
g_registry_registration_error.clear();
g_registry_target = nullptr;
// Snapshot the registration state before invoking the plugin so
// that a partial success followed by a failure (callback registers
// three tables, then returns false) doesn't leak registrations a
// retry would then reject as duplicates.
const size_t snap_tables_admin = tables_admin_.size();
const size_t snap_tables_config = tables_config_.size();
const size_t snap_tables_stats = tables_stats_.size();
const size_t snap_commands = commands_.size();
const size_t snap_table_storage = table_storage_.size();
bool phase_b_ok;
bool registration_failed;
std::string registration_error;
{
ScopedRegistryTarget target_guard(this);
phase_b_ok = register_schemas_cb(&services_phase_b_);
registration_failed = g_registry_registration_failed;
registration_error = g_registry_registration_error;
}
auto rollback = [&]() {
tables_admin_.resize(snap_tables_admin);
tables_config_.resize(snap_tables_config);
tables_stats_.resize(snap_tables_stats);
commands_.resize(snap_commands);
while (table_storage_.size() > snap_table_storage) {
table_storage_.pop_back();
}
};
if (!phase_b_ok) {
rollback();
err = "plugin register_schemas failed: " + plugin_name(plugin.descriptor);
return false;
}
if (registration_failed) {
rollback();
err = "plugin register_schemas failed: " + plugin_name(plugin.descriptor);
if (!registration_error.empty()) {
err += ": " + registration_error;
@ -388,20 +455,39 @@ bool ProxySQL_PluginManager::init_all(std::string &err) {
plugin.initialized = true;
continue;
}
g_registry_target = this;
g_registry_registration_failed = false;
g_registry_registration_error.clear();
const bool init_ok = plugin.descriptor->init(&services_);
const bool registration_failed = g_registry_registration_failed;
const std::string registration_error = g_registry_registration_error;
g_registry_registration_failed = false;
g_registry_registration_error.clear();
g_registry_target = nullptr;
// Same rollback contract as invoke_register_schemas_phase: on
// failure, trim any registrations this plugin performed so a
// retry doesn't duplicate-fail.
const size_t snap_tables_admin = tables_admin_.size();
const size_t snap_tables_config = tables_config_.size();
const size_t snap_tables_stats = tables_stats_.size();
const size_t snap_commands = commands_.size();
const size_t snap_table_storage = table_storage_.size();
bool init_ok;
bool registration_failed;
std::string registration_error;
{
ScopedRegistryTarget target_guard(this);
init_ok = plugin.descriptor->init(&services_);
registration_failed = g_registry_registration_failed;
registration_error = g_registry_registration_error;
}
auto rollback = [&]() {
tables_admin_.resize(snap_tables_admin);
tables_config_.resize(snap_tables_config);
tables_stats_.resize(snap_tables_stats);
commands_.resize(snap_commands);
while (table_storage_.size() > snap_table_storage) {
table_storage_.pop_back();
}
};
if (!init_ok) {
rollback();
err = "plugin init failed: " + plugin_name(plugin.descriptor);
return false;
}
if (registration_failed) {
rollback();
err = "plugin init failed: " + plugin_name(plugin.descriptor);
if (!registration_error.empty()) {
err += ": " + registration_error;
@ -443,8 +529,14 @@ bool ProxySQL_PluginManager::stop_all() {
bool ok = true;
for (auto it = plugins_.rbegin(); it != plugins_.rend(); ++it) {
if (!it->started) {
continue; // Only stop plugins that were actually started.
// stop() pairs with init() for teardown symmetry: any plugin
// that succeeded init() gets stop() called, even if start()
// later failed. Otherwise resources the plugin allocated in
// init (config stores, worker threads, metric gauges, ...)
// leak on the init-success/start-fail path. Plugins that
// never reached init are skipped.
if (!it->initialized) {
continue;
}
if (it->stopped) {
continue;
@ -699,22 +791,22 @@ bool ProxySQL_PluginManager::register_command_alias(const char* canonical_sql, c
return false;
}
const char* ProxySQL_PluginManager::resolve_alias_to_canonical(const std::string& sql) const {
std::string ProxySQL_PluginManager::resolve_alias_to_canonical(const std::string& sql) const {
const std::string canonical_sql = canonicalize_plugin_command(sql);
if (canonical_sql.empty()) {
return nullptr;
return {};
}
for (const auto& command : commands_) {
if (sql_equals_ci(command.sql, canonical_sql)) {
return command.sql.c_str();
return command.sql;
}
for (const auto& alias : command.aliases) {
if (sql_equals_ci(alias, canonical_sql)) {
return command.sql.c_str();
return command.sql;
}
}
}
return nullptr;
return {};
}
#endif /* PROXYSQL40 */
@ -736,11 +828,15 @@ bool proxysql_dispatch_configured_plugin_admin_command(
}
#ifdef PROXYSQL40
const char* proxysql_resolve_configured_plugin_admin_alias(const std::string& sql) {
std::string proxysql_resolve_configured_plugin_admin_alias(const std::string& sql) {
// Return-by-value (not const char*) intentional: the alias table lives
// in the manager, and the caller typically releases the lock before
// dispatching. A borrowed c_str() would dangle if the manager is swapped
// out on reload between resolve and dispatch. Copy out under the lock.
std::lock_guard<std::mutex> lock(g_active_plugin_manager_mutex);
ProxySQL_PluginManager* mgr = g_active_plugin_manager.load();
if (mgr == nullptr) {
return nullptr;
return {};
}
return mgr->resolve_alias_to_canonical(sql);
}
@ -820,6 +916,17 @@ bool proxysql_load_configured_plugins(
// proxysql_get_plugin_manager() — used by
// ProxySQL_Admin::materialize_plugin_tables — can find the
// registered tables.
//
// INVARIANT (publish-before-Phase-D): after this point Phase D
// (init_all via proxysql_init_configured_plugins) WILL still write
// to commands_ / mysql_query_hook_ / pgsql_query_hook_ on the
// published manager. This is only safe because Phase D runs during
// single-threaded startup — before ProxySQL_Main_init_phase3___
// start_all spawns the threads that take the lock-free read path
// (proxysql_has_configured_plugin_query_hook, Admin_Handler alias
// resolution). Any reordering that moves listener startup before
// proxysql_init_configured_plugins() returns will race plain writes
// against plain reads.
{
std::lock_guard<std::mutex> lock(g_active_plugin_manager_mutex);
manager = std::move(next_manager);
@ -870,6 +977,22 @@ bool proxysql_init_configured_plugins(
// ProxySQL_Main_init_Admin_module has materialized plugin-owned
// tables, so each plugin's init() sees live DB handles against a
// schema that already contains its own tables.
//
// ORDERING INVARIANT: caller MUST invoke this BEFORE any thread
// that takes the lock-free read path on the manager
// (MySQL_Thread / PgSQL_Thread dispatch_query_hook, Admin_Handler
// alias resolution) comes up. See src/main.cpp:
// ProxySQL_Main_init_phase2___not_started — runs Phase D
// ProxySQL_Main_init_phase3___start_all — spawns workers
// Phase 3 must run strictly after Phase 2 returns.
//
// FAILURE MODE: if this function returns false, the caller in
// src/main.cpp calls exit(EXIT_FAILURE) — Phase D failure is a
// fatal startup error. The published manager is left in place;
// plugins that succeeded init() will have stop_all() called during
// process teardown (see stop_all's "initialized -> stop()"
// contract). Runtime reload of plugin_modules is NOT supported by
// this code path: it is callable from the startup codepath only.
err.clear();
if (manager == nullptr) {
return true;

@ -24,7 +24,36 @@ PROTO_OBJS := $(patsubst $(PROTO_DIR)/%.pb.cc,$(ODIR)/%.pb.o,$(PROTO_SRCS))
IDIRS := -I$(PROXYSQL_IDIR) -I$(PLUGIN_DIR)/include -I$(SQLITE3_IDIR) -I$(PROTO_DIR) -I$(SSL_IDIR) -I$(PROMETHEUS_IDIR) -I$(LIBCONFIG_IDIR) -I$(JEMALLOC_IDIR) -I$(MARIADB_IDIR) -I$(RE2_IDIR)
OPTZ ?= -O2 -ggdb
CXXFLAGS := $(STDCPP) -fPIC $(OPTZ) $(WGCOV) $(WASAN) -pthread
# Propagate feature-tier flags from the top-level build. These MUST match
# the flags libproxysql.a was built with; a mismatch silently changes the
# ProxySQL_PluginDescriptor / ProxySQL_PluginServices layout the core and
# the plugin each see, and the loader then reads past the end of the
# plugin's static descriptor. (If this Makefile is run standalone, the
# caller is expected to export PROXYSQL40/31/etc. to match the lib.)
PSQL40 :=
ifeq ($(PROXYSQL40),1)
PSQL40 := -DPROXYSQL40
endif
PSQL31 :=
ifeq ($(PROXYSQL31),1)
PSQL31 := -DPROXYSQL31
endif
PSQLFFTO :=
ifeq ($(PROXYSQLFFTO),1)
PSQLFFTO := -DPROXYSQLFFTO
endif
PSQLTSDB :=
ifeq ($(PROXYSQLTSDB),1)
PSQLTSDB := -DPROXYSQLTSDB
endif
PSQLGA :=
ifeq ($(PROXYSQLGENAI),1)
PSQLGA := -DPROXYSQLGENAI
endif
CXXFLAGS := $(STDCPP) -fPIC $(OPTZ) $(WGCOV) $(WASAN) -pthread \
$(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(PSQLGA)
.DEFAULT_GOAL := all

@ -247,7 +247,7 @@ const char* mysqlx_status_json() {
const ProxySQL_PluginDescriptor mysqlx_descriptor = {
"mysqlx",
1,
PROXYSQL_PLUGIN_ABI_VERSION,
&mysqlx_init,
&mysqlx_start,
&mysqlx_stop,

@ -76,19 +76,46 @@ void fake_log_event(const char *event) {
// lifecycle contract says they must be null).
bool fake_register_schemas(ProxySQL_PluginServices *services) {
fake_services = services;
// _PHASE_B_PARTIAL_THEN_FAIL: register a table first, then return
// false. Tests that the loader rolls back the registration so a
// retry doesn't trip on the leftover.
if (env("PHASE_B_PARTIAL_THEN_FAIL") != nullptr &&
services != nullptr &&
services->register_table != nullptr) {
const ProxySQL_PluginTableDef table {
ProxySQL_PluginDBKind::admin_db,
FAKE_PLUGIN_NAME "_partial_table",
"CREATE TABLE " FAKE_PLUGIN_NAME "_partial_table (id INTEGER)"
};
services->register_table(table);
fake_log_event("phase_b_partial_then_fail");
return false;
}
if (env("PHASE_B_FAIL") != nullptr) {
fake_log_event("phase_b_fail");
return false;
}
if (env("PHASE_B_TOUCH_HANDLES") != nullptr &&
services != nullptr) {
SQLite3DB* a = (services->get_admindb != nullptr) ? services->get_admindb() : nullptr;
SQLite3DB* c = (services->get_configdb != nullptr) ? services->get_configdb() : nullptr;
SQLite3DB* s = (services->get_statsdb != nullptr) ? services->get_statsdb() : nullptr;
if (a == nullptr && c == nullptr && s == nullptr) {
fake_log_event("phase_b_handles_null");
// Contract: during Phase B the getters are non-null stub
// functions that return nullptr. A nullptr function pointer
// would mean plugins can't even call them unconditionally —
// that breaks the contract just as much as returning a live
// handle does. Distinguish the three outcomes so the test can
// assert the exact one we advertise.
if (services->get_admindb == nullptr ||
services->get_configdb == nullptr ||
services->get_statsdb == nullptr) {
fake_log_event("phase_b_getter_null");
} else {
fake_log_event("phase_b_handles_live");
SQLite3DB* a = services->get_admindb();
SQLite3DB* c = services->get_configdb();
SQLite3DB* s = services->get_statsdb();
if (a == nullptr && c == nullptr && s == nullptr) {
fake_log_event("phase_b_handles_null");
} else {
fake_log_event("phase_b_handles_live");
}
}
}
if (env("PHASE_B_REGISTER_TABLE") != nullptr &&
@ -128,6 +155,17 @@ bool fake_init(ProxySQL_PluginServices *services) {
const char* sql = env("REGISTER_COMMAND_SQL");
services->register_command(sql != nullptr ? sql : "PLUGIN FAKE NOOP", &fake_command);
}
#ifdef PROXYSQL40
if (env("REGISTER_COMMAND_ALIAS") != nullptr &&
services != nullptr &&
services->register_command_alias != nullptr) {
const char* canonical = env("REGISTER_COMMAND_ALIAS_CANONICAL");
const char* alias = env("REGISTER_COMMAND_ALIAS_SQL");
if (canonical != nullptr && alias != nullptr) {
services->register_command_alias(canonical, alias);
}
}
#endif /* PROXYSQL40 */
if (env("REGISTER_TABLE") != nullptr &&
services != nullptr &&
services->register_table != nullptr) {
@ -188,7 +226,9 @@ const char *fake_status_json() {
// Pre-Step-2.2 descriptor layout (six fields). Used when the plugin is
// NOT opting into Phase B -- register_schemas is implicitly null because
// the field is absent. Leaves us testing that plugins built against the
// older descriptor still work under the new loader.
// older descriptor still work under the new loader. abi_version stays
// at 1 regardless of the compile-time PROXYSQL40 flag so this descriptor
// represents the "legacy plugin" shape the v4 loader has to accept.
const ProxySQL_PluginDescriptor fake_descriptor = {
FAKE_PLUGIN_NAME,
1,
@ -201,9 +241,10 @@ const ProxySQL_PluginDescriptor fake_descriptor = {
#ifdef PROXYSQL40
// Phase-B-aware descriptor: same as above but wires the register_schemas
// entry. Selected at plugin-discovery time when the env toggle is set.
// abi_version 2 tells the loader this descriptor has the seventh field.
const ProxySQL_PluginDescriptor fake_descriptor_with_phase_b = {
FAKE_PLUGIN_NAME,
1,
2,
&fake_init,
&fake_start,
&fake_stop,
@ -212,9 +253,24 @@ const ProxySQL_PluginDescriptor fake_descriptor_with_phase_b = {
};
#endif /* PROXYSQL40 */
// Descriptor with a bogus ABI version -- used by lifecycle tests to
// verify the loader's version check rejects unknown ABIs rather than
// reading past the end of the plugin's struct.
const ProxySQL_PluginDescriptor fake_descriptor_bogus_abi = {
FAKE_PLUGIN_NAME,
99,
&fake_init,
&fake_start,
&fake_stop,
&fake_status_json,
};
} // namespace
extern "C" const ProxySQL_PluginDescriptor *proxysql_plugin_descriptor_v1() {
if (env("FORCE_BOGUS_ABI") != nullptr) {
return &fake_descriptor_bogus_abi;
}
#ifdef PROXYSQL40
if (env("ENABLE_PHASE_B") != nullptr) {
return &fake_descriptor_with_phase_b;

@ -191,11 +191,6 @@ ifneq ($(shell nm $(LIBPROXYSQLAR) 2>/dev/null | grep -c MySQLFFTO),0)
PROXYSQLFFTO := 1
PSQLFFTO := -DPROXYSQLFFTO
endif
PSQL31 :=
ifneq ($(shell nm $(LIBPROXYSQLAR) 2>/dev/null | grep -c MySQLFFTO),0)
PROXYSQL31 := 1
PSQL31 := -DPROXYSQL31
endif
# PROXYSQL40 — detected via a chassis-exclusive symbol that only exists
# when libproxysql.a was built with -DPROXYSQL40 (the four-phase plugin
@ -211,6 +206,16 @@ ifneq ($(shell nm $(LIBPROXYSQLAR) 2>/dev/null | grep -c init_tsdb_variables),0)
PSQLTSDB := -DPROXYSQLTSDB
endif
# PROXYSQL31 is the parent tier flag for FFTO + TSDB; the codebase has no
# #ifdef PROXYSQL31 blocks of its own, so autodetect it as "at least one
# of FFTO or TSDB is enabled". This matches the top-level Makefile's
# cascade (PROXYSQL31=1 => PROXYSQLFFTO=1 && PROXYSQLTSDB=1).
PSQL31 :=
ifneq ($(PROXYSQLFFTO)$(PROXYSQLTSDB),)
PROXYSQL31 := 1
PSQL31 := -DPROXYSQL31
endif
ifeq ($(PROXYSQLGENAI),1)
STATIC_LIBS += $(SQLITE3_LDIR)/../libsqlite_rembed.a $(SQLITE3_LDIR)/vec.o
endif
@ -266,7 +271,10 @@ $(FAKE_PLUGIN_SO): $(TEST_HELPERS_DIR)/fake_plugin.cpp $(PROXYSQL_PATH)/include/
mysqlx_plugin_build:
$(MAKE) -C $(PROXYSQL_PATH)/plugins/mysqlx all \
CC=$(CC) CXX=$(CXX) OPTZ="$(MYSQLX_PLUGIN_OPTZ)" \
WGCOV="$(WGCOV)" WASAN="$(WASAN)"
WGCOV="$(WGCOV)" WASAN="$(WASAN)" \
PROXYSQLGENAI=$(PROXYSQLGENAI) PROXYSQL40=$(PROXYSQL40) \
PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) \
PROXYSQLTSDB=$(PROXYSQLTSDB)
# Same source compiled with different macros to produce a second plugin .so
# with a distinct plugin name and env-var prefix. Used by multi-plugin tests.
@ -412,9 +420,10 @@ plugin_config_unit-t: plugin_config_unit-t.cpp $(FAKE_PLUGIN_SO) $(FAKE_PLUGIN2_
$(IDIRS) $(LDIRS) $(OPT) $(LIBPROXYSQLAR_FULL) $(STATIC_LIBS) \
$(MYLIBS) -ldl $(ALLOW_MULTI_DEF) -o $@
plugin_dispatch_unit-t: plugin_dispatch_unit-t.cpp $(FAKE_PLUGIN_SO) $(ODIR)/tap.o $(ODIR)/test_globals.o $(ODIR)/test_init.o $(LIBPROXYSQLAR)
plugin_dispatch_unit-t: plugin_dispatch_unit-t.cpp $(FAKE_PLUGIN_SO) $(FAKE_PLUGIN2_SO) $(ODIR)/tap.o $(ODIR)/test_globals.o $(ODIR)/test_init.o $(LIBPROXYSQLAR)
$(CXX) $< $(ODIR)/tap.o $(ODIR)/test_globals.o $(ODIR)/test_init.o \
-DPROXYSQL_FAKE_PLUGIN_PATH=\"$(FAKE_PLUGIN_SO)\" \
-DPROXYSQL_FAKE_PLUGIN2_PATH=\"$(FAKE_PLUGIN2_SO)\" \
$(IDIRS) $(LDIRS) $(OPT) $(LIBPROXYSQLAR_FULL) $(STATIC_LIBS) \
$(MYLIBS) -ldl $(ALLOW_MULTI_DEF) -o $@

@ -305,15 +305,15 @@ static void test_alias_dispatch_two_non_conflicting_plugins() {
// resolve_alias_to_canonical returns the canonical, regardless of
// which spelling was used to probe it.
ok(std::string(mgr->resolve_alias_to_canonical("PLUGIN_A RELOAD")) == "PLUGIN_A SYNC CONFIG",
ok(mgr->resolve_alias_to_canonical("PLUGIN_A RELOAD") == "PLUGIN_A SYNC CONFIG",
"resolve_alias_to_canonical maps alias -> canonical for plugin A");
ok(std::string(mgr->resolve_alias_to_canonical("PLUGIN_B PURGE")) == "PLUGIN_B FLUSH CACHE",
ok(mgr->resolve_alias_to_canonical("PLUGIN_B PURGE") == "PLUGIN_B FLUSH CACHE",
"resolve_alias_to_canonical maps alias -> canonical for plugin B");
ok(mgr->resolve_alias_to_canonical("UNKNOWN") == nullptr,
"resolve_alias_to_canonical returns nullptr for unknown spellings");
ok(mgr->resolve_alias_to_canonical("UNKNOWN").empty(),
"resolve_alias_to_canonical returns empty for unknown spellings");
// Whitespace/case normalization on the lookup side.
ok(std::string(mgr->resolve_alias_to_canonical(" plugin_a reload ")) == "PLUGIN_A SYNC CONFIG",
ok(mgr->resolve_alias_to_canonical(" plugin_a reload ") == "PLUGIN_A SYNC CONFIG",
"resolve_alias_to_canonical normalizes whitespace and case");
// Negative: an alias cannot shadow a different command's canonical
@ -325,11 +325,49 @@ static void test_alias_dispatch_two_non_conflicting_plugins() {
ok(mgr->register_command_alias("PLUGIN_A SYNC CONFIG", "PLUGIN_A RELOAD"),
"register_command_alias is idempotent for the same canonical+alias pair");
}
// Real cross-plugin alias collision: two independent .so plugins loaded
// side-by-side, both trying to own the same admin command spelling.
// The second plugin's init MUST fail and the loader MUST report the
// collision rather than silently letting one win. Exercises the full
// dlopen + init_all + register_command path, not just the same-manager
// API-level collision.
#ifndef PROXYSQL_FAKE_PLUGIN2_PATH
#error "PROXYSQL_FAKE_PLUGIN2_PATH must be defined"
#endif
static void test_cross_plugin_command_collision() {
// Both plugins try to register the SAME admin-command SQL. The
// loader processes plugins in the order passed, so plugin2 is the
// one that finds the spelling already taken; its init must fail.
setenv("PROXYSQL_FAKE_PLUGIN_REGISTER_COMMAND", "1", 1);
setenv("PROXYSQL_FAKE_PLUGIN_REGISTER_COMMAND_SQL", "PLUGIN SHARED CMD", 1);
setenv("PROXYSQL_FAKE_PLUGIN2_REGISTER_COMMAND", "1", 1);
setenv("PROXYSQL_FAKE_PLUGIN2_REGISTER_COMMAND_SQL", "PLUGIN SHARED CMD", 1);
std::unique_ptr<ProxySQL_PluginManager> mgr;
std::vector<std::string> paths {
PROXYSQL_FAKE_PLUGIN_PATH,
PROXYSQL_FAKE_PLUGIN2_PATH
};
std::string err;
bool loaded = proxysql_load_configured_plugins(mgr, paths, err);
bool inited = loaded && proxysql_init_configured_plugins_compat(mgr.get(), err);
ok(!inited,
"load+init fails when two plugins claim the same admin command");
ok(!err.empty(),
"collision produces an error message (err='%s')", err.c_str());
(void)proxysql_stop_configured_plugins(mgr, err);
unsetenv("PROXYSQL_FAKE_PLUGIN_REGISTER_COMMAND");
unsetenv("PROXYSQL_FAKE_PLUGIN_REGISTER_COMMAND_SQL");
unsetenv("PROXYSQL_FAKE_PLUGIN2_REGISTER_COMMAND");
unsetenv("PROXYSQL_FAKE_PLUGIN2_REGISTER_COMMAND_SQL");
}
#endif /* PROXYSQL40 */
int main() {
#ifdef PROXYSQL40
plan(50);
plan(52);
#else
plan(32);
#endif
@ -344,6 +382,7 @@ int main() {
test_start_when_null_manager();
#ifdef PROXYSQL40
test_alias_dispatch_two_non_conflicting_plugins();
test_cross_plugin_command_collision();
#endif
return exit_status();

@ -127,11 +127,22 @@ static void test_only_init_skips_phase_b() {
(void)proxysql_stop_configured_plugins(mgr, err);
}
// Case 3: register_schemas tries to call DB handle getters. The loader
// passes a services struct whose DB-handle getters are stubs returning
// nullptr. The plugin logs which it observed; we assert it saw null.
// This pins down the lifecycle contract that the spec requires plugins
// to respect.
// 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);
@ -149,6 +160,8 @@ static void test_phase_b_db_handles_are_null() {
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");
@ -180,8 +193,91 @@ static void test_phase_b_failure_aborts_init() {
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<ProxySQL_PluginManager> mgr1;
std::vector<std::string> 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<ProxySQL_PluginManager> 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<ProxySQL_PluginManager> mgr;
std::vector<std::string> 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<ProxySQL_PluginManager> mgr;
std::vector<std::string> 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(14);
plan(26);
make_log_path();
@ -189,6 +285,9 @@ int main() {
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();

@ -209,8 +209,63 @@ static void test_global_dispatcher_with_active_manager() {
"dispatcher returns false after stop");
}
// query_text is a pointer+length pair, not a C string. The hook must
// use `query_len` to bound reads; plugins that strlen() the pointer will
// truncate at the first embedded NUL (and leak data to plugins they
// shouldn't see, if length is shorter than NUL offset). This test pins
// the contract on the dispatch side.
static void test_embedded_nul_in_query_text() {
ProxySQL_PluginManager mgr;
mgr.register_query_hook(ProxySQL_PluginProtocol::mysql, &echo_hook);
const char raw[] = "SELECT\0id FROM t"; // 16 bytes incl. trailing \0
ProxySQL_PluginQueryHookPayload p {
"u", "ip", "s", raw, static_cast<uint32_t>(sizeof(raw) - 1)
};
ProxySQL_PluginQueryHookResult r {ProxySQL_PluginQueryHookAction::deny, ""};
ok(mgr.dispatch_query_hook(ProxySQL_PluginProtocol::mysql, p, r),
"dispatch handles a payload with an embedded NUL in query_text");
ok(r.message.size() == std::string("u/ip/s:").size() + sizeof(raw) - 1,
"hook received all %zu bytes — not truncated at the embedded NUL", sizeof(raw) - 1);
}
// A hook callback that itself calls dispatch_query_hook for the OTHER
// protocol must not deadlock or crash. The manager's dispatch path
// takes no lock, so re-entry is safe; this test locks that down.
static ProxySQL_PluginManager* g_reentrant_mgr = nullptr;
static int g_reentrant_inner_calls = 0;
ProxySQL_PluginQueryHookResult reentrant_outer_hook(const ProxySQL_PluginQueryHookPayload& outer) {
ProxySQL_PluginQueryHookResult inner {ProxySQL_PluginQueryHookAction::deny, ""};
// Re-enter from mysql hook into the pgsql protocol's hook.
if (g_reentrant_mgr != nullptr) {
auto p = payload_for("u", "ip", "s", "inner");
if (g_reentrant_mgr->dispatch_query_hook(ProxySQL_PluginProtocol::pgsql, p, inner)) {
g_reentrant_inner_calls++;
}
}
return {ProxySQL_PluginQueryHookAction::allow, std::string("outer:") + std::string(outer.query_text, outer.query_len)};
}
static void test_reentrant_dispatch_across_protocols() {
ProxySQL_PluginManager mgr;
g_reentrant_mgr = &mgr;
g_reentrant_inner_calls = 0;
mgr.register_query_hook(ProxySQL_PluginProtocol::mysql, &reentrant_outer_hook);
mgr.register_query_hook(ProxySQL_PluginProtocol::pgsql, &always_allow_hook);
auto p = payload_for("u", "ip", "s", "outer");
ProxySQL_PluginQueryHookResult r {ProxySQL_PluginQueryHookAction::deny, ""};
ok(mgr.dispatch_query_hook(ProxySQL_PluginProtocol::mysql, p, r),
"re-entrant dispatch (mysql hook → pgsql hook) returns without deadlock");
ok(g_reentrant_inner_calls == 1,
"inner (pgsql) hook fired exactly once during outer (mysql) hook");
ok(r.action == ProxySQL_PluginQueryHookAction::allow,
"outer hook's ALLOW propagated to caller despite re-entry");
g_reentrant_mgr = nullptr;
}
int main() {
plan(41);
plan(46);
test_unregistered_protocols_have_no_hook();
test_register_and_dispatch_allow();
@ -219,6 +274,8 @@ int main() {
test_duplicate_hook_rejected();
test_protocols_independent();
test_payload_threaded_through();
test_embedded_nul_in_query_text();
test_reentrant_dispatch_across_protocols();
test_global_dispatcher_no_active_manager();
test_global_dispatcher_with_active_manager();

Loading…
Cancel
Save