From 43dfb30a96fc42c0eeafe8b0d381720c54ccbeed Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 20 Jul 2026 15:31:32 +0800 Subject: [PATCH 1/7] fix(plugin): make the hot reload of plugins transactional `plugin.load()` used to tear the live plugin tables down before rebuilding them: it destroyed every old plugin, cleared `local_plugins` / `local_plugins_hash` in place, and then re-required and re-`init()`ed the plugins one by one, with no protection and no rollback. An unprotected `init()` therefore leaves the worker permanently broken: the plugins that had already been loaded stay in the array (`load_plugin` inserted before calling `init()`, so even the failing plugin stays), the `local_plugins_hash` rebuild never runs so the hash stays empty and the Admin API rejects every plugin with `unknown plugin [...]`, and which plugins survive depends on the `pairs()` order, so it differs per worker. The error is swallowed by the worker-events pcall while the reload endpoint has already answered `200 done`. Rework `load()` / `load_stream()` into three phases: 1. build the new plugin set in a local table, so the tables read by the request path are untouched while modules are required; 2. destroy the old instances, then run the `init()` / `workflow_handler()` hooks of the new ones under pcall. On failure, destroy the new instances, restore the `package.loaded` snapshot and re-init the old instances, then return the error; 3. on success, repopulate the live tables in place. The old instances are destroyed before the new ones are initialized (rather than the other way around) because plugins such as server-info and log-rotate register timers globally by name, so an overlap would let the old `destroy()` unregister the timer the new instance just registered. The live tables are repopulated in place rather than swapped, because `_M.plugins` and the `ipairs(plugin_mod.plugins)` readers in api_router / control router alias them; there is no yield point between the clear and the end of the loop, so no request can observe a partially updated set. That also closes the second part of the issue: modules are required before the commit, not during it. The Admin `/apisix/admin/plugins/reload` and Control `/v1/plugins/reload` endpoints now load on the serving worker first and only broadcast the event if that succeeds, so a plugin set that cannot be loaded is reported as `500` instead of an unconditional `200 done`. The event handlers skip the originating worker id to avoid loading twice. Fixes #13087 --- apisix/admin/init.lua | 37 +++- apisix/control/router.lua | 13 +- apisix/control/v1.lua | 9 + apisix/plugin.lua | 284 ++++++++++++++++++++++----- docs/en/latest/admin-api.md | 8 + docs/en/latest/control-api.md | 4 + t/admin/plugins-reload-transaction.t | 188 ++++++++++++++++++ t/apisix/plugins/reload-bad-init.lua | 33 ++++ 8 files changed, 521 insertions(+), 55 deletions(-) create mode 100644 t/admin/plugins-reload-transaction.t create mode 100644 t/apisix/plugins/reload-bad-init.lua diff --git a/apisix/admin/init.lua b/apisix/admin/init.lua index c3f543e62949..a189bc76b7a8 100644 --- a/apisix/admin/init.lua +++ b/apisix/admin/init.lua @@ -285,9 +285,22 @@ local function unsupported_methods_reload_plugin() end +-- defined after sync_local_conf_to_etcd +local reload_plugins_and_sync + + local function post_reload_plugins() set_ctx_and_check_token() + -- reload on this worker first: if the new plugin set cannot be loaded, + -- report the error to the operator instead of an unconditional "done", + -- and don't broadcast the event to the other workers + local ok, err = reload_plugins_and_sync() + if not ok then + core.log.error("failed to hot reload plugins: ", err) + core.response.exit(500, {error_msg = "failed to reload plugins: " .. err}) + end + local success, err = events:post(reload_event, get_method(), ngx_time()) if not success then core.response.exit(503, err) @@ -373,13 +386,33 @@ local function sync_local_conf_to_etcd(reset) end -local function reload_plugins(data, event, source, pid) +function reload_plugins_and_sync() core.log.info("start to hot reload plugins") - plugin.load() + local ok, err = plugin.load() + if not ok then + return nil, err + end if ngx_worker_id() == 0 then sync_local_conf_to_etcd() end + + return true +end + + +local function reload_plugins(data, event, source, wid) + if wid == ngx_worker_id() then + -- this worker has already reloaded synchronously while serving the + -- Admin API request, see post_reload_plugins() + return + end + + local ok, err = reload_plugins_and_sync() + if not ok then + core.log.error("failed to hot reload plugins: ", err, + ", this worker keeps the old plugin set") + end end diff --git a/apisix/control/router.lua b/apisix/control/router.lua index e5044bf54be2..356f72d2ca32 100644 --- a/apisix/control/router.lua +++ b/apisix/control/router.lua @@ -199,9 +199,18 @@ end end -- do -local function reload_plugins() +local function reload_plugins(data, event, source, wid) + if wid == ngx.worker.id() then + -- already reloaded synchronously in post_reload_plugins() + return + end + core.log.info("start to hot reload plugins") - plugin_mod.load() + local ok, err = plugin_mod.load() + if not ok then + core.log.error("failed to hot reload plugins: ", err, + ", this worker keeps the old plugin set") + end end diff --git a/apisix/control/v1.lua b/apisix/control/v1.lua index 496b8b57d5fe..72bde7c82e5b 100644 --- a/apisix/control/v1.lua +++ b/apisix/control/v1.lua @@ -409,6 +409,15 @@ function _M.dump_plugin_metadata() end function _M.post_reload_plugins() + -- reload on this worker first so that a plugin set which cannot be loaded + -- is reported to the caller instead of being broadcast + core.log.info("start to hot reload plugins") + local ok, err = plugin.load() + if not ok then + core.log.error("failed to hot reload plugins: ", err) + core.response.exit(500, {error_msg = "failed to reload plugins: " .. err}) + end + local success, err = events:post(_M.RELOAD_EVENT, ngx.req.get_method(), ngx.time()) if not success then core.response.exit(503, err) diff --git a/apisix/plugin.lua b/apisix/plugin.lua index 65179364e741..37e71eaba2c7 100644 --- a/apisix/plugin.lua +++ b/apisix/plugin.lua @@ -166,24 +166,92 @@ end local PLUGIN_TYPE_HTTP = 1 local PLUGIN_TYPE_STREAM = 2 local PLUGIN_TYPE_HTTP_WASM = 3 -local function unload_plugin(name, plugin_type) + +local function plugin_pkg_name(name, plugin_type) + if plugin_type == PLUGIN_TYPE_STREAM then + return "apisix.stream.plugins." .. name + end + + return "apisix.plugins." .. name +end + + +-- whether the init/destroy hooks of this plugin type run in the current +-- subsystem: stream plugins are also loaded in the HTTP subsystem so that +-- the Admin API can validate their schemas, but their hooks must only run +-- in the stream subsystem; wasm plugins have no init/destroy hooks +local function has_lifecycle(plugin_type) if plugin_type == PLUGIN_TYPE_HTTP_WASM then - return + return false end - -- Don't unload stream plugins in the HTTP subsystem. if plugin_type == PLUGIN_TYPE_STREAM and is_http then + return false + end + + return true +end + + +local function http_plugin_type(plugin) + if plugin.type == "wasm" then + return PLUGIN_TYPE_HTTP_WASM + end + + return PLUGIN_TYPE_HTTP +end + + +local function destroy_plugin(plugin, plugin_type) + if not has_lifecycle(plugin_type) then return end - local pkg_name = "apisix.plugins." .. name - if plugin_type == PLUGIN_TYPE_STREAM then - pkg_name = "apisix.stream.plugins." .. name + if type(plugin.destroy) ~= "function" then + return + end + + local ok, err = pcall(plugin.destroy) + if not ok then + core.log.error("failed to destroy plugin [", plugin.name, "]: ", err) + end +end + + +local function init_plugin(plugin, plugin_type) + if not has_lifecycle(plugin_type) then + return true end + if plugin.init then + local ok, err = pcall(plugin.init) + if not ok then + return nil, "failed to init plugin [" .. tostring(plugin.name) + .. "]: " .. tostring(err) + end + end + + if plugin.workflow_handler then + local ok, err = pcall(plugin.workflow_handler) + if not ok then + return nil, "failed to run the workflow handler of plugin [" + .. tostring(plugin.name) .. "]: " .. tostring(err) + end + end + + return true +end + + +local function unload_plugin(name, plugin_type) + if not has_lifecycle(plugin_type) then + return + end + + local pkg_name = plugin_pkg_name(name, plugin_type) local old_plugin = pkg_loaded[pkg_name] - if old_plugin and type(old_plugin.destroy) == "function" then - old_plugin.destroy() + if old_plugin then + destroy_plugin(old_plugin, plugin_type) end pkg_loaded[pkg_name] = nil @@ -197,12 +265,7 @@ local function load_plugin(name, plugins_list, plugin_type) ok, plugin = wasm.require(name) name = name.name else - local pkg_name = "apisix.plugins." .. name - if plugin_type == PLUGIN_TYPE_STREAM then - pkg_name = "apisix.stream.plugins." .. name - end - - ok, plugin = pcall(require, pkg_name) + ok, plugin = pcall(require, plugin_pkg_name(name, plugin_type)) end if not ok then @@ -252,21 +315,9 @@ local function load_plugin(name, plugins_list, plugin_type) plugin.attr = plugin_attr(name) core.table.insert(plugins_list, plugin) - -- Don't initialize stream plugins in the HTTP subsystem. - -- The modules are loaded for schema validation (admin API), - -- but init/workflow_handler functions must only run in the stream subsystem. - if plugin_type == PLUGIN_TYPE_STREAM and is_http then - return - end - - if plugin.init then - plugin.init() - end - - if plugin.workflow_handler then - plugin.workflow_handler() - end - + -- the init/workflow_handler hooks are not run here: they are run by the + -- caller once the whole new plugin set has been built, so that a failing + -- hook can be rolled back without leaving a half-built plugin table return end @@ -286,32 +337,95 @@ local function load(plugin_names, wasm_plugin_names) core.log.warn("new plugins: ", core.json.delay_encode(processed)) - for name, plugin in pairs(local_plugins_hash) do - local ty = PLUGIN_TYPE_HTTP - if plugin.type == "wasm" then - ty = PLUGIN_TYPE_HTTP_WASM + -- phase 1: build the new plugin set in a local table. The tables read by + -- the request path (local_plugins / local_plugins_hash) keep serving and + -- stay untouched if anything below fails. Drop the cached modules first + -- so that require() re-reads the code from disk, and keep a snapshot of + -- them for the rollback. + local pkg_snapshot = {} + for name, value in pairs(processed) do + if type(value) ~= "table" then + local pkg_name = plugin_pkg_name(name, PLUGIN_TYPE_HTTP) + pkg_snapshot[pkg_name] = pkg_loaded[pkg_name] or false + pkg_loaded[pkg_name] = nil end - unload_plugin(name, ty) end - core.table.clear(local_plugins) - core.table.clear(local_plugins_hash) - + local new_plugins = core.table.new(32, 0) for name, value in pairs(processed) do local ty = PLUGIN_TYPE_HTTP if type(value) == "table" then ty = PLUGIN_TYPE_HTTP_WASM name = value end - load_plugin(name, local_plugins, ty) + load_plugin(name, new_plugins, ty) end -- sort by plugin's priority - if #local_plugins > 1 then - sort_tab(local_plugins, sort_plugin) + if #new_plugins > 1 then + sort_tab(new_plugins, sort_plugin) + end + + -- phase 2: destroy the old instances first, then run the init hooks of the + -- new instances. The order matters: some plugins register global resources + -- keyed by name (timers.register_timer), so a new instance registering + -- before the old one unregisters would lose the resource. If any hook + -- fails, roll everything back and keep serving with the current set. + local old_plugins = core.table.clone(local_plugins) + for _, old_plugin in ipairs(old_plugins) do + destroy_plugin(old_plugin, http_plugin_type(old_plugin)) end - for i, plugin in ipairs(local_plugins) do + local load_err + for _, plugin in ipairs(new_plugins) do + local ok, err = init_plugin(plugin, http_plugin_type(plugin)) + if not ok then + load_err = err + break + end + end + + if load_err then + for _, plugin in ipairs(new_plugins) do + destroy_plugin(plugin, http_plugin_type(plugin)) + end + + for pkg_name, mod in pairs(pkg_snapshot) do + pkg_loaded[pkg_name] = mod or nil + end + + for _, old_plugin in ipairs(old_plugins) do + local ok, err = init_plugin(old_plugin, http_plugin_type(old_plugin)) + if not ok then + core.log.error("failed to restore the old plugin after the ", + "aborted reload: ", err) + end + end + + return nil, load_err + end + + -- phase 3: commit. Unload the modules of the removed plugins, then + -- repopulate the live tables in place: their identity never changes + -- (_M.plugins / _M.plugins_hash keep pointing to them) and there is no + -- yield point between the clear and the end of the loop, so concurrent + -- requests never observe a partially updated plugin set. + local new_names = core.table.new(0, #new_plugins) + for _, plugin in ipairs(new_plugins) do + new_names[plugin.name] = true + end + + for name, old_plugin in pairs(local_plugins_hash) do + if not new_names[name] and old_plugin.type ~= "wasm" then + pkg_loaded[plugin_pkg_name(name, PLUGIN_TYPE_HTTP)] = nil + end + end + + core.table.clear(local_plugins) + core.table.clear(local_plugins_hash) + + for i, plugin in ipairs(new_plugins) do + local_plugins[i] = plugin local_plugins_hash[plugin.name] = plugin if enable_debug() then core.log.warn("loaded plugin and sort by priority:", @@ -336,23 +450,78 @@ local function load_stream(plugin_names) core.log.warn("new plugins: ", core.json.delay_encode(processed)) - for name in pairs(stream_local_plugins_hash) do - unload_plugin(name, PLUGIN_TYPE_STREAM) + -- the three phases below mirror load(), see the comments there + local pkg_snapshot = {} + if has_lifecycle(PLUGIN_TYPE_STREAM) then + for name in pairs(processed) do + local pkg_name = plugin_pkg_name(name, PLUGIN_TYPE_STREAM) + pkg_snapshot[pkg_name] = pkg_loaded[pkg_name] or false + pkg_loaded[pkg_name] = nil + end end - core.table.clear(stream_local_plugins) - core.table.clear(stream_local_plugins_hash) - + local new_plugins = core.table.new(32, 0) for name in pairs(processed) do - load_plugin(name, stream_local_plugins, PLUGIN_TYPE_STREAM) + load_plugin(name, new_plugins, PLUGIN_TYPE_STREAM) end -- sort by plugin's priority - if #stream_local_plugins > 1 then - sort_tab(stream_local_plugins, sort_plugin) + if #new_plugins > 1 then + sort_tab(new_plugins, sort_plugin) + end + + local old_plugins = core.table.clone(stream_local_plugins) + for _, old_plugin in ipairs(old_plugins) do + destroy_plugin(old_plugin, PLUGIN_TYPE_STREAM) + end + + local load_err + for _, plugin in ipairs(new_plugins) do + local ok, err = init_plugin(plugin, PLUGIN_TYPE_STREAM) + if not ok then + load_err = err + break + end + end + + if load_err then + for _, plugin in ipairs(new_plugins) do + destroy_plugin(plugin, PLUGIN_TYPE_STREAM) + end + + for pkg_name, mod in pairs(pkg_snapshot) do + pkg_loaded[pkg_name] = mod or nil + end + + for _, old_plugin in ipairs(old_plugins) do + local ok, err = init_plugin(old_plugin, PLUGIN_TYPE_STREAM) + if not ok then + core.log.error("failed to restore the old stream plugin after ", + "the aborted reload: ", err) + end + end + + return nil, load_err + end + + if has_lifecycle(PLUGIN_TYPE_STREAM) then + local new_names = core.table.new(0, #new_plugins) + for _, plugin in ipairs(new_plugins) do + new_names[plugin.name] = true + end + + for name in pairs(stream_local_plugins_hash) do + if not new_names[name] then + pkg_loaded[plugin_pkg_name(name, PLUGIN_TYPE_STREAM)] = nil + end + end end - for i, plugin in ipairs(stream_local_plugins) do + core.table.clear(stream_local_plugins) + core.table.clear(stream_local_plugins_hash) + + for i, plugin in ipairs(new_plugins) do + stream_local_plugins[i] = plugin stream_local_plugins_hash[plugin.name] = plugin if enable_debug() then core.log.warn("loaded stream plugin and sort by priority:", @@ -413,6 +582,7 @@ function _M.load(config) return local_plugins end + local load_err if ngx.config.subsystem == "http" then if not http_plugin_names then core.log.error("failed to read plugin list from local file") @@ -425,6 +595,7 @@ function _M.load(config) local ok, err = load(http_plugin_names, wasm_plugin_names) if not ok then core.log.error("failed to load plugins: ", err) + load_err = err end end end @@ -435,9 +606,14 @@ function _M.load(config) local ok, err = load_stream(stream_plugin_names) if not ok then core.log.error("failed to load stream plugins: ", err) + load_err = load_err or err end end + if load_err then + return nil, load_err + end + -- for test return local_plugins end @@ -921,7 +1097,13 @@ end function _M.init_worker() -- someone's plugin needs to be initialized after prometheus -- see https://github.com/apache/apisix/issues/3286 - _M.load() + local _, err = _M.load() + if err then + -- fail loudly on the initial load, like the unprotected init() used + -- to: starting with a silently reduced plugin set would fail open, + -- e.g. the auth plugins would simply be skipped + error("failed to load the plugins: " .. err) + end if local_conf and not local_conf.apisix.enable_admin then init_plugins_syncer() diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index 31496f1aadfd..9ee7307bc1be 100644 --- a/docs/en/latest/admin-api.md +++ b/docs/en/latest/admin-api.md @@ -1473,6 +1473,14 @@ The interface of getting properties of all plugins via `/apisix/admin/plugins?al ::: +:::note + +If the new plugin list cannot be loaded, for instance because the `init()` function of +one of the plugins fails, `/apisix/admin/plugins/reload` returns `500` together with the +error message and every worker keeps serving with its previous plugin set. + +::: + ### Request Body Parameters The Plugin ({plugin_name}) of the data structure. diff --git a/docs/en/latest/control-api.md b/docs/en/latest/control-api.md index 4a5e6e9a5008..729dcfded73d 100644 --- a/docs/en/latest/control-api.md +++ b/docs/en/latest/control-api.md @@ -486,6 +486,10 @@ Triggers a hot reload of the plugins. curl "http://127.0.0.1:9090/v1/plugins/reload" -X PUT ``` +If the new plugin list cannot be loaded, for instance because the `init()` function of one +of the plugins fails, the endpoint returns `500` together with the error message and every +worker keeps serving with its previous plugin set. + ### GET /v1/discovery/{service}/dump Get memory dump of discovered service endpoints and configuration details: diff --git a/t/admin/plugins-reload-transaction.t b/t/admin/plugins-reload-transaction.t new file mode 100644 index 000000000000..fc5536821109 --- /dev/null +++ b/t/admin/plugins-reload-transaction.t @@ -0,0 +1,188 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); +no_shuffle(); +log_level("info"); +# a single worker so that the Admin API request and the plugin table +# inspected afterwards always belong to the same worker +workers(1); + +run_tests; + +__DATA__ + +=== TEST 1: a plugin whose init() throws aborts the reload and rolls it back +--- yaml_config +apisix: + node_listen: 1984 +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +plugins: + - response-rewrite +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local core = require("apisix.core") + local http = require("resty.http") + + local route_conf = [[{ + "uri": "/hello", + "plugins": {"response-rewrite": {"body": "REWRITTEN\n"}}, + "upstream": {"nodes": {"127.0.0.1:1980": 1}, "type": "roundrobin"} + }]] + + local code = t('/apisix/admin/routes/1', ngx.HTTP_PUT, route_conf) + ngx.say("admin PUT before reload: ", code) + + ngx.sleep(0.6) + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" + local res = http.new():request_uri(uri) + ngx.print("dataplane before reload: ", res.body) + + -- keep response-rewrite and add a plugin whose init() throws + require("lib.test_admin").set_config_yaml([[ +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +apisix: + node_listen: 1984 +plugins: + - response-rewrite + - reload-bad-init +]]) + local code2, _, body2 = t('/apisix/admin/plugins/reload', ngx.HTTP_PUT) + ngx.say("reload: ", code2, " ", body2) + ngx.sleep(2) + + -- the live plugin tables must still hold the previous plugin set: + -- plugin.plugins is read by the request path, plugin.plugins_hash by + -- the Admin API schema validation + local plugin = require("apisix.plugin") + local names = {} + for _, p in ipairs(plugin.plugins) do + core.table.insert(names, p.name) + end + ngx.say("after reload: plugins_hash has response-rewrite=", + plugin.plugins_hash["response-rewrite"] ~= nil, + ", plugins array=[", core.table.concat(names, ","), "]") + + -- the very same route conf is still accepted + local code3 = t('/apisix/admin/routes/1', ngx.HTTP_PUT, route_conf) + ngx.say("admin PUT after reload: ", code3) + + local res2 = http.new():request_uri(uri) + ngx.print("dataplane after reload: ", res2.body) + } +} +--- request +GET /t +--- response_body eval +qr/^admin PUT before reload: 201 +dataplane before reload: REWRITTEN +reload: 500 \{"error_msg":"failed to reload plugins: failed to init plugin \[reload-bad-init\].*boom.*"\} +after reload: plugins_hash has response-rewrite=true, plugins array=\[response-rewrite\] +admin PUT after reload: 200 +dataplane after reload: REWRITTEN +$/s +--- timeout: 15 +--- error_log eval +qr/reload-bad-init: init\(\) boom/ + + + +=== TEST 2: a failed reload leaves no sticky state, the next one succeeds +--- yaml_config +apisix: + node_listen: 1984 +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +plugins: + - response-rewrite +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local core = require("apisix.core") + + require("lib.test_admin").set_config_yaml([[ +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +apisix: + node_listen: 1984 +plugins: + - response-rewrite + - reload-bad-init +]]) + local code, _, body = t('/apisix/admin/plugins/reload', ngx.HTTP_PUT) + ngx.say("failing reload: ", code) + ngx.sleep(1) + + require("lib.test_admin").set_config_yaml([[ +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +apisix: + node_listen: 1984 +plugins: + - response-rewrite + - key-auth +]]) + local code2, _, body2 = t('/apisix/admin/plugins/reload', ngx.HTTP_PUT) + ngx.say("recovering reload: ", code2, " ", body2) + ngx.sleep(1) + + local plugin = require("apisix.plugin") + local names = {} + for _, p in ipairs(plugin.plugins) do + core.table.insert(names, p.name) + end + table.sort(names) + ngx.say("plugins array=[", core.table.concat(names, ","), "]") + ngx.say("key-auth in hash: ", plugin.plugins_hash["key-auth"] ~= nil) + } +} +--- request +GET /t +--- response_body +failing reload: 500 +recovering reload: 200 done +plugins array=[key-auth,response-rewrite] +key-auth in hash: true +--- timeout: 15 diff --git a/t/apisix/plugins/reload-bad-init.lua b/t/apisix/plugins/reload-bad-init.lua new file mode 100644 index 000000000000..3d845800c66e --- /dev/null +++ b/t/apisix/plugins/reload-bad-init.lua @@ -0,0 +1,33 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- A test only plugin whose init() always throws, used to check that a failing +-- plugin reload is rolled back instead of leaving a half-built plugin table. +local _M = { + version = 0.1, + priority = 412, + name = "reload-bad-init", + schema = {type = "object"}, +} + + +function _M.init() + error("reload-bad-init: init() boom") +end + + +return _M From 04f68ab0022e81176467e21986bd8d1908d5d0fd Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 21 Jul 2026 07:45:38 +0800 Subject: [PATCH 2/7] test(plugin): fix the reload-transaction test harness usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test() returns (status, body, headers) on an error, so `code, _, body` put the headers table into body and ngx.say aborted on it — the whole block produced empty output. Take the body as the second return value. Also declare the expected init() error so the default no_error_log guard does not flag it, and tolerate the create-route status (200 on EE, 201 upstream) plus the trailing newline in the error body. --- t/admin/plugins-reload-transaction.t | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/t/admin/plugins-reload-transaction.t b/t/admin/plugins-reload-transaction.t index fc5536821109..f97d726f253e 100644 --- a/t/admin/plugins-reload-transaction.t +++ b/t/admin/plugins-reload-transaction.t @@ -76,7 +76,7 @@ plugins: - response-rewrite - reload-bad-init ]]) - local code2, _, body2 = t('/apisix/admin/plugins/reload', ngx.HTTP_PUT) + local code2, body2 = t('/apisix/admin/plugins/reload', ngx.HTTP_PUT) ngx.say("reload: ", code2, " ", body2) ngx.sleep(2) @@ -103,9 +103,9 @@ plugins: --- request GET /t --- response_body eval -qr/^admin PUT before reload: 201 +qr/^admin PUT before reload: 20[01] dataplane before reload: REWRITTEN -reload: 500 \{"error_msg":"failed to reload plugins: failed to init plugin \[reload-bad-init\].*boom.*"\} +reload: 500 \{"error_msg":"failed to reload plugins: failed to init plugin \[reload-bad-init\].*boom.*"\}\s* after reload: plugins_hash has response-rewrite=true, plugins array=\[response-rewrite\] admin PUT after reload: 200 dataplane after reload: REWRITTEN @@ -186,3 +186,5 @@ recovering reload: 200 done plugins array=[key-auth,response-rewrite] key-auth in hash: true --- timeout: 15 +--- error_log eval +qr/reload-bad-init: init\(\) boom/ From 2f5fbd82f6624cd200c107b5b97f742981f42b4d Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 21 Jul 2026 08:40:50 +0800 Subject: [PATCH 3/7] fix(plugin): only drop cached modules of already-loaded plugins load()/load_stream() cleared package.loaded for the whole target plugin set before requiring, so the very first load (init_worker) re-required every plugin and threw away the module instances initialized in init_by_lua. A plugin's destroy() registered there was lost (t/node/plugin.t), and any module-level init done in init_by_lua ran twice. Match master: drop only the currently-loaded set (empty on first load, so those modules are reused), which still re-reads code from disk on a reload. --- apisix/plugin.lua | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apisix/plugin.lua b/apisix/plugin.lua index 37e71eaba2c7..e48272144e89 100644 --- a/apisix/plugin.lua +++ b/apisix/plugin.lua @@ -339,12 +339,14 @@ local function load(plugin_names, wasm_plugin_names) -- phase 1: build the new plugin set in a local table. The tables read by -- the request path (local_plugins / local_plugins_hash) keep serving and - -- stay untouched if anything below fails. Drop the cached modules first - -- so that require() re-reads the code from disk, and keep a snapshot of - -- them for the rollback. + -- stay untouched if anything below fails. Drop the cached modules of the + -- currently loaded plugins so require() re-reads their code from disk on a + -- reload, and snapshot them for the rollback. Only the already-loaded set + -- is dropped: on the first load there is nothing to drop, so the modules + -- initialized in init_by_lua are reused as-is. local pkg_snapshot = {} - for name, value in pairs(processed) do - if type(value) ~= "table" then + for name, old_plugin in pairs(local_plugins_hash) do + if old_plugin.type ~= "wasm" then local pkg_name = plugin_pkg_name(name, PLUGIN_TYPE_HTTP) pkg_snapshot[pkg_name] = pkg_loaded[pkg_name] or false pkg_loaded[pkg_name] = nil @@ -450,10 +452,12 @@ local function load_stream(plugin_names) core.log.warn("new plugins: ", core.json.delay_encode(processed)) - -- the three phases below mirror load(), see the comments there + -- the three phases below mirror load(), see the comments there. Only the + -- already-loaded stream plugins are dropped, so the first load reuses the + -- modules initialized in init_by_lua. local pkg_snapshot = {} if has_lifecycle(PLUGIN_TYPE_STREAM) then - for name in pairs(processed) do + for name in pairs(stream_local_plugins_hash) do local pkg_name = plugin_pkg_name(name, PLUGIN_TYPE_STREAM) pkg_snapshot[pkg_name] = pkg_loaded[pkg_name] or false pkg_loaded[pkg_name] = nil From 3b1fe78b4297df98352c01745a65f35b5e225680 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 3 Aug 2026 13:26:26 +0800 Subject: [PATCH 4/7] fix(plugin): don't destroy plugin instances which were never initialized The rollback of an aborted reload destroyed every instance of the new plugin set, including the ones sitting after the failing plugin, whose init() had never run. destroy() of an uninitialized instance publishes its uninitialized state: gm and ocsp-stapling assign their nil upvalue back to radixtree_sni.set_cert_and_key, which breaks the SSL handshake path and is then saved as the "original" function by the rollback, so the damage survives the reload. Both plugins have a lower priority than almost everything else, so any failing init() reaches them. Destroy only the instances whose init() completed, and do it in the reverse order of init(), so that plugins wrapping a shared function unwind their chain innermost first. The old instances are destroyed in reverse order too, for the same reason. --- apisix/plugin.lua | 30 ++++++++----- t/admin/plugins-reload-transaction.t | 66 ++++++++++++++++++++++++++++ t/apisix/plugins/reload-probe.lua | 48 ++++++++++++++++++++ t/lib/reload_probe_state.lua | 26 +++++++++++ 4 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 t/apisix/plugins/reload-probe.lua create mode 100644 t/lib/reload_probe_state.lua diff --git a/apisix/plugin.lua b/apisix/plugin.lua index e48272144e89..ed92b127d7a7 100644 --- a/apisix/plugin.lua +++ b/apisix/plugin.lua @@ -373,23 +373,31 @@ local function load(plugin_names, wasm_plugin_names) -- keyed by name (timers.register_timer), so a new instance registering -- before the old one unregisters would lose the resource. If any hook -- fails, roll everything back and keep serving with the current set. + -- destroy() runs in the reverse order of init(): plugins which wrap a + -- shared function (gm, ocsp-stapling) restore what they saved, so the + -- innermost wrapper has to be removed first. Only the new instances whose + -- init() actually ran are destroyed during the rollback: destroy() of an + -- instance that was never initialized would publish its uninitialized + -- state, e.g. set radixtree_sni.set_cert_and_key to a nil upvalue. local old_plugins = core.table.clone(local_plugins) - for _, old_plugin in ipairs(old_plugins) do - destroy_plugin(old_plugin, http_plugin_type(old_plugin)) + for i = #old_plugins, 1, -1 do + destroy_plugin(old_plugins[i], http_plugin_type(old_plugins[i])) end local load_err - for _, plugin in ipairs(new_plugins) do + local inited = 0 + for i, plugin in ipairs(new_plugins) do local ok, err = init_plugin(plugin, http_plugin_type(plugin)) if not ok then load_err = err break end + inited = i end if load_err then - for _, plugin in ipairs(new_plugins) do - destroy_plugin(plugin, http_plugin_type(plugin)) + for i = inited, 1, -1 do + destroy_plugin(new_plugins[i], http_plugin_type(new_plugins[i])) end for pkg_name, mod in pairs(pkg_snapshot) do @@ -475,22 +483,24 @@ local function load_stream(plugin_names) end local old_plugins = core.table.clone(stream_local_plugins) - for _, old_plugin in ipairs(old_plugins) do - destroy_plugin(old_plugin, PLUGIN_TYPE_STREAM) + for i = #old_plugins, 1, -1 do + destroy_plugin(old_plugins[i], PLUGIN_TYPE_STREAM) end local load_err - for _, plugin in ipairs(new_plugins) do + local inited = 0 + for i, plugin in ipairs(new_plugins) do local ok, err = init_plugin(plugin, PLUGIN_TYPE_STREAM) if not ok then load_err = err break end + inited = i end if load_err then - for _, plugin in ipairs(new_plugins) do - destroy_plugin(plugin, PLUGIN_TYPE_STREAM) + for i = inited, 1, -1 do + destroy_plugin(new_plugins[i], PLUGIN_TYPE_STREAM) end for pkg_name, mod in pairs(pkg_snapshot) do diff --git a/t/admin/plugins-reload-transaction.t b/t/admin/plugins-reload-transaction.t index f97d726f253e..6f6a7f7e644e 100644 --- a/t/admin/plugins-reload-transaction.t +++ b/t/admin/plugins-reload-transaction.t @@ -188,3 +188,69 @@ key-auth in hash: true --- timeout: 15 --- error_log eval qr/reload-bad-init: init\(\) boom/ + + + +=== TEST 3: the rollback only destroys the new instances which were initialized +--- yaml_config +apisix: + node_listen: 1984 +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +plugins: + - response-rewrite + - reload-probe +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local state = require("lib.reload_probe_state") + + -- the initial load has initialized the old reload-probe instance + ngx.say("after start: init=", state.init, " destroy=", state.destroy) + + -- reload-bad-init has a higher priority than reload-probe, so it fails + -- before the new reload-probe instance is initialized + require("lib.test_admin").set_config_yaml([[ +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +apisix: + node_listen: 1984 +plugins: + - response-rewrite + - reload-bad-init + - reload-probe +]]) + local code = t('/apisix/admin/plugins/reload', ngx.HTTP_PUT) + ngx.say("reload: ", code) + ngx.sleep(1) + + -- the old instance was destroyed once and re-initialized by the + -- rollback; the new one was never initialized, so it must never have + -- been destroyed either + ngx.say("after rollback: init=", state.init, " destroy=", state.destroy, + " destroy_without_init=", state.destroy_without_init) + + local plugin = require("apisix.plugin") + ngx.say("reload-probe still live: ", + plugin.plugins_hash["reload-probe"] ~= nil) + } +} +--- request +GET /t +--- response_body +after start: init=1 destroy=0 +reload: 500 +after rollback: init=2 destroy=1 destroy_without_init=0 +reload-probe still live: true +--- timeout: 15 +--- error_log eval +qr/reload-bad-init: init\(\) boom/ diff --git a/t/apisix/plugins/reload-probe.lua b/t/apisix/plugins/reload-probe.lua new file mode 100644 index 000000000000..4d5a1b6a497f --- /dev/null +++ b/t/apisix/plugins/reload-probe.lua @@ -0,0 +1,48 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- A test only plugin recording its lifecycle hooks. Its priority is lower than +-- the one of reload-bad-init, so it is initialized after it: when the reload is +-- aborted this instance has never been initialized and must not be destroyed. +local state = require("lib.reload_probe_state") + +local inited = false + +local _M = { + version = 0.1, + priority = 411, + name = "reload-probe", + schema = {type = "object"}, +} + + +function _M.init() + inited = true + state.init = state.init + 1 +end + + +function _M.destroy() + if not inited then + state.destroy_without_init = state.destroy_without_init + 1 + end + + state.destroy = state.destroy + 1 +end + + +return _M diff --git a/t/lib/reload_probe_state.lua b/t/lib/reload_probe_state.lua new file mode 100644 index 000000000000..28022edcd0b7 --- /dev/null +++ b/t/lib/reload_probe_state.lua @@ -0,0 +1,26 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- Records the lifecycle hooks the reload-probe test plugin has seen. It lives +-- outside the apisix.plugins package so that the plugin loader never drops it +-- from package.loaded, hence the counters survive a reload. +return { + init = 0, + destroy = 0, + -- destroy() calls on an instance whose init() never ran + destroy_without_init = 0, +} From aaa96b5eeb4100964b7dab16425d2097ced8b81f Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 3 Aug 2026 14:15:19 +0800 Subject: [PATCH 5/7] test(plugin): pin the destroy order and fix the unwind comment The reverse-order destroy had no coverage: reverting it alone left the suite green. TEST 4 asserts the hook sequence of two probe plugins across an aborted reload, which pins both the LIFO unwind of the old set and the fact that a reload failing on the very first plugin (inited = 0) destroys nothing of the new set. The comment claimed the innermost wrapper is removed first, which is the opposite of what the code does: gm installs first and is the inner layer, so it is ocsp-stapling, the outermost, that has to go first. --- apisix/plugin.lua | 17 ++++---- t/admin/plugins-reload-transaction.t | 58 ++++++++++++++++++++++++++++ t/apisix/plugins/reload-probe-2.lua | 52 +++++++++++++++++++++++++ t/apisix/plugins/reload-probe.lua | 6 ++- t/lib/reload_probe_state.lua | 14 ++++++- 5 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 t/apisix/plugins/reload-probe-2.lua diff --git a/apisix/plugin.lua b/apisix/plugin.lua index ed92b127d7a7..108654f3f800 100644 --- a/apisix/plugin.lua +++ b/apisix/plugin.lua @@ -374,14 +374,16 @@ local function load(plugin_names, wasm_plugin_names) -- before the old one unregisters would lose the resource. If any hook -- fails, roll everything back and keep serving with the current set. -- destroy() runs in the reverse order of init(): plugins which wrap a - -- shared function (gm, ocsp-stapling) restore what they saved, so the - -- innermost wrapper has to be removed first. Only the new instances whose - -- init() actually ran are destroyed during the rollback: destroy() of an - -- instance that was never initialized would publish its uninitialized - -- state, e.g. set radixtree_sni.set_cert_and_key to a nil upvalue. + -- shared function (gm, then ocsp-stapling on top of it) restore what they + -- saved, so the wrappers have to be unwound LIFO, the last one installed + -- first. Only the new instances whose init() actually ran are destroyed + -- during the rollback: destroy() of an instance that was never initialized + -- would publish its uninitialized state, e.g. set + -- radixtree_sni.set_cert_and_key to a nil upvalue. local old_plugins = core.table.clone(local_plugins) for i = #old_plugins, 1, -1 do - destroy_plugin(old_plugins[i], http_plugin_type(old_plugins[i])) + local old_plugin = old_plugins[i] + destroy_plugin(old_plugin, http_plugin_type(old_plugin)) end local load_err @@ -397,7 +399,8 @@ local function load(plugin_names, wasm_plugin_names) if load_err then for i = inited, 1, -1 do - destroy_plugin(new_plugins[i], http_plugin_type(new_plugins[i])) + local plugin = new_plugins[i] + destroy_plugin(plugin, http_plugin_type(plugin)) end for pkg_name, mod in pairs(pkg_snapshot) do diff --git a/t/admin/plugins-reload-transaction.t b/t/admin/plugins-reload-transaction.t index 6f6a7f7e644e..71a0f9cb3771 100644 --- a/t/admin/plugins-reload-transaction.t +++ b/t/admin/plugins-reload-transaction.t @@ -254,3 +254,61 @@ reload-probe still live: true --- timeout: 15 --- error_log eval qr/reload-bad-init: init\(\) boom/ + + + +=== TEST 4: the destroy hooks run in the reverse order of the init hooks +--- yaml_config +apisix: + node_listen: 1984 +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +plugins: + - reload-probe + - reload-probe-2 +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local core = require("apisix.core") + local state = require("lib.reload_probe_state") + + -- reload-bad-init has the highest priority of the three, so it fails + -- before any new instance is initialized: nothing of the new set may + -- be destroyed, and the old set is unwound in the reverse order of + -- its init() and then restored in the init() order + require("lib.test_admin").set_config_yaml([[ +deployment: + role: traditional + role_traditional: + config_provider: etcd + admin: + admin_key: null +apisix: + node_listen: 1984 +plugins: + - reload-probe + - reload-bad-init + - reload-probe-2 +]]) + local code = t('/apisix/admin/plugins/reload', ngx.HTTP_PUT) + ngx.say("reload: ", code) + ngx.sleep(1) + + ngx.say("hooks: ", core.table.concat(state.events, " ")) + ngx.say("destroy_without_init=", state.destroy_without_init) + } +} +--- request +GET /t +--- response_body +reload: 500 +hooks: reload-probe:init reload-probe-2:init reload-probe-2:destroy reload-probe:destroy reload-probe:init reload-probe-2:init +destroy_without_init=0 +--- timeout: 15 +--- error_log eval +qr/reload-bad-init: init\(\) boom/ diff --git a/t/apisix/plugins/reload-probe-2.lua b/t/apisix/plugins/reload-probe-2.lua new file mode 100644 index 000000000000..2934f3114d29 --- /dev/null +++ b/t/apisix/plugins/reload-probe-2.lua @@ -0,0 +1,52 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- A second lifecycle probe, with a lower priority than reload-probe, so that +-- the two are initialized in a known order and the destroy order can be +-- asserted against it. +local state = require("lib.reload_probe_state") + +local plugin_name = "reload-probe-2" +local inited = false + +local _M = { + version = 0.1, + priority = 410, + name = plugin_name, + schema = {type = "object"}, +} + + +function _M.init() + inited = true + state.init = state.init + 1 + state.record(plugin_name, "init") +end + + +function _M.destroy() + if not inited then + state.destroy_without_init = state.destroy_without_init + 1 + end + + inited = false + state.destroy = state.destroy + 1 + state.record(plugin_name, "destroy") +end + + +return _M diff --git a/t/apisix/plugins/reload-probe.lua b/t/apisix/plugins/reload-probe.lua index 4d5a1b6a497f..76883649db18 100644 --- a/t/apisix/plugins/reload-probe.lua +++ b/t/apisix/plugins/reload-probe.lua @@ -20,12 +20,13 @@ -- aborted this instance has never been initialized and must not be destroyed. local state = require("lib.reload_probe_state") +local plugin_name = "reload-probe" local inited = false local _M = { version = 0.1, priority = 411, - name = "reload-probe", + name = plugin_name, schema = {type = "object"}, } @@ -33,6 +34,7 @@ local _M = { function _M.init() inited = true state.init = state.init + 1 + state.record(plugin_name, "init") end @@ -41,7 +43,9 @@ function _M.destroy() state.destroy_without_init = state.destroy_without_init + 1 end + inited = false state.destroy = state.destroy + 1 + state.record(plugin_name, "destroy") end diff --git a/t/lib/reload_probe_state.lua b/t/lib/reload_probe_state.lua index 28022edcd0b7..3ec287fc5b6b 100644 --- a/t/lib/reload_probe_state.lua +++ b/t/lib/reload_probe_state.lua @@ -15,12 +15,22 @@ -- limitations under the License. -- --- Records the lifecycle hooks the reload-probe test plugin has seen. It lives +-- Records the lifecycle hooks the reload-probe test plugins have seen. It lives -- outside the apisix.plugins package so that the plugin loader never drops it -- from package.loaded, hence the counters survive a reload. -return { +local _M = { init = 0, destroy = 0, -- destroy() calls on an instance whose init() never ran destroy_without_init = 0, + -- ":" entries in the order the hooks ran + events = {}, } + + +function _M.record(name, hook) + _M.events[#_M.events + 1] = name .. ":" .. hook +end + + +return _M From 96ca0a382547a1b7cacba172c8847d65e3ce98ef Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 3 Aug 2026 14:20:56 +0800 Subject: [PATCH 6/7] change(plugin): unwind the plugin set LIFO on worker exit too exit_worker() iterated the hash, i.e. in an arbitrary order, which is the one remaining path that does not respect the unwind order the reload now guarantees. Impact is near zero since the process is going away, but leaving it inconsistent invites the next reader to copy the wrong pattern. --- apisix/plugin.lua | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apisix/plugin.lua b/apisix/plugin.lua index 108654f3f800..68ae90702ade 100644 --- a/apisix/plugin.lua +++ b/apisix/plugin.lua @@ -637,19 +637,17 @@ end function _M.exit_worker() - for name, plugin in pairs(local_plugins_hash) do - local ty = PLUGIN_TYPE_HTTP - if plugin.type == "wasm" then - ty = PLUGIN_TYPE_HTTP_WASM - end - unload_plugin(name, ty) + -- same LIFO unwind as a reload does, see load() + for i = #local_plugins, 1, -1 do + local plugin = local_plugins[i] + unload_plugin(plugin.name, http_plugin_type(plugin)) end -- we need to load stream plugin so that we can check their schemas in -- Admin API. Maybe we can avoid calling `load` in this case? So that -- we don't need to call `destroy` too - for name in pairs(stream_local_plugins_hash) do - unload_plugin(name, PLUGIN_TYPE_STREAM) + for i = #stream_local_plugins, 1, -1 do + unload_plugin(stream_local_plugins[i].name, PLUGIN_TYPE_STREAM) end end From a160887173a91a80e3b890a539d8c3f0448abca4 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 4 Aug 2026 12:31:39 +0800 Subject: [PATCH 7/7] fix(plugin): drop the modules an aborted reload cached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkg_snapshot only records the plugins that were live before the reload, so a plugin the reload introduces has no entry there. Restoring the snapshot therefore left its module in package.loaded, and phase 1 of the next reload only drops the modules of the live set — which does not include it. require() kept handing back the stale module even after the operator fixed the file on disk, and only a restart cleared it, which contradicts the point of re-reading plugin code on reload. Clear the modules of the whole new set before restoring the snapshot: the plugins present in both sets are put back by the snapshot, and the ones this reload introduced stay gone. --- apisix/plugin.lua | 20 ++++++++++++++++++++ t/admin/plugins-reload-transaction.t | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/apisix/plugin.lua b/apisix/plugin.lua index 45b70f01c61b..a381573d163d 100644 --- a/apisix/plugin.lua +++ b/apisix/plugin.lua @@ -403,6 +403,18 @@ local function load(plugin_names, wasm_plugin_names) destroy_plugin(plugin, http_plugin_type(plugin)) end + -- drop what this aborted reload cached: a plugin the reload introduces + -- has no pkg_snapshot entry, so restoring the snapshot alone would + -- leave its module in package.loaded. Phase 1 of the next reload only + -- drops the modules of the live set, which does not include it, so + -- require() would keep returning the stale module even after the + -- operator fixed the file on disk. + for _, plugin in ipairs(new_plugins) do + if plugin.type ~= "wasm" then + pkg_loaded[plugin_pkg_name(plugin.name, PLUGIN_TYPE_HTTP)] = nil + end + end + for pkg_name, mod in pairs(pkg_snapshot) do pkg_loaded[pkg_name] = mod or nil end @@ -506,6 +518,14 @@ local function load_stream(plugin_names) destroy_plugin(new_plugins[i], PLUGIN_TYPE_STREAM) end + -- see load(): the modules this aborted reload cached have to go, or a + -- later reload would not re-read them from disk + if has_lifecycle(PLUGIN_TYPE_STREAM) then + for _, plugin in ipairs(new_plugins) do + pkg_loaded[plugin_pkg_name(plugin.name, PLUGIN_TYPE_STREAM)] = nil + end + end + for pkg_name, mod in pairs(pkg_snapshot) do pkg_loaded[pkg_name] = mod or nil end diff --git a/t/admin/plugins-reload-transaction.t b/t/admin/plugins-reload-transaction.t index 71a0f9cb3771..2825959387ed 100644 --- a/t/admin/plugins-reload-transaction.t +++ b/t/admin/plugins-reload-transaction.t @@ -242,6 +242,11 @@ plugins: local plugin = require("apisix.plugin") ngx.say("reload-probe still live: ", plugin.plugins_hash["reload-probe"] ~= nil) + + -- the module the aborted reload pulled in must not stay cached, or a + -- fixed plugin file would not be re-read by the next reload + ngx.say("bad-init still cached: ", + package.loaded["apisix.plugins.reload-bad-init"] ~= nil) } } --- request @@ -251,6 +256,7 @@ after start: init=1 destroy=0 reload: 500 after rollback: init=2 destroy=1 destroy_without_init=0 reload-probe still live: true +bad-init still cached: false --- timeout: 15 --- error_log eval qr/reload-bad-init: init\(\) boom/