Enhance request pipeline with safe operation dispatch, support for paused server states, and bridge status updates.

This commit is contained in:
2026-08-27 11:57:02 +02:00
parent 439105ec60
commit dd801de28d
2 changed files with 43 additions and 5 deletions

View File

@@ -1,8 +1,18 @@
---@diagnostic disable: need-check-nil
-- Registration deferred to register(), called from RDB_Bootstrap's -- Registration deferred to register(), called from RDB_Bootstrap's
-- onServerStarted, see RDB_OpWorldGetStats.lua. -- onServerStarted, see RDB_OpWorldGetStats.lua.
RDB_OpBridgeStatus = {} RDB_OpBridgeStatus = {}
-- Mirrors zombie.GameTime.isGamePaused()'s server-side branch exactly
-- (decompiled: `GameServer.Players.isEmpty() && ServerOptions.instance.pauseEmpty.getValue()`),
-- true precisely when RDB_RequestPipeline would be relying on Events.OnTickEvenPaused
-- instead of Events.OnTick to even see this request.
local function isPaused()
return ServerOptions.instance:getBoolean("PauseEmpty") and getOnlinePlayers():size() == 0
end
function RDB_OpBridgeStatus.register() function RDB_OpBridgeStatus.register()
RDB_OpRegistry.register("get_bridge_status", { RDB_OpRegistry.register("get_bridge_status", {
handler = function(_) handler = function(_)
@@ -13,6 +23,7 @@ function RDB_OpBridgeStatus.register()
version = RDB_Constants.PROTOCOL_VERSION, version = RDB_Constants.PROTOCOL_VERSION,
bootedAt = bootedAt, bootedAt = bootedAt,
uptimeSeconds = (now - bootedAt) / 1000, uptimeSeconds = (now - bootedAt) / 1000,
paused = isPaused(),
rateLimit = RDB_Security.getRateLimitStatus(), rateLimit = RDB_Security.getRateLimitStatus(),
config = RDB_Config.getAll(), config = RDB_Config.getAll(),
} }

View File

@@ -44,6 +44,19 @@ local function audit(id, op, ok, code)
RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code }) RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code })
end end
-- If an op handler throws (a bug, a bad deploy, a version mismatch between
-- files), surface it as a fast INTERNAL_ERROR response instead of silently
-- swallowing it. The raw Lua error is logged server-side only, never
-- sent to the client.
local function safeDispatch(op, args)
local ok, a, b, c = pcall(RDB_OpRegistry.dispatch, op, args)
if not ok then
RDB_Log.error("op '" .. tostring(op) .. "' handler threw: " .. tostring(a))
return false, RDB_Constants.ERROR_CODES.INTERNAL_ERROR, "Unhandled error in operation handler."
end
return a, b, c
end
-- Handles one raw request string end to end. raw is base64: RCON's -- Handles one raw request string end to end. raw is base64: RCON's
-- changeoption command strips every literal double-quote character from -- changeoption command strips every literal double-quote character from
-- every argument (see RESEARCH_LOG.md), so a client can't send raw JSON -- -- every argument (see RESEARCH_LOG.md), so a client can't send raw JSON --
@@ -109,7 +122,7 @@ local function processRequest(rawBase64)
return return
end end
local dispatchOk, dataOrCode, message = RDB_OpRegistry.dispatch(op, args) local dispatchOk, dataOrCode, message = safeDispatch(op, args)
local response local response
if dispatchOk then if dispatchOk then
response = buildOkResponse(id, dataOrCode) response = buildOkResponse(id, dataOrCode)
@@ -148,14 +161,28 @@ end
local initialized = false local initialized = false
-- Guarded: Events.OnServerStarted fires more than once per boot (see -- Guarded: Events.OnServerStarted fires more than once per boot,
-- RESEARCH_LOG.md), and Events.OnTick.Add does not deduplicate identical -- and Events.OnTick.Add does not deduplicate identical listeners,
-- listeners, so calling this twice would otherwise double-register onTick. -- so calling this twice would otherwise double-register onTick.
--
-- Registered on BOTH OnTick and OnTickEvenPaused: decompiling GameWindow's
-- main loop shows these two are mutually exclusive per real engine tick;
-- normal ticks fire OnTick (via IngameState's update path), while a tick
-- where the server is paused (PauseEmpty=true and no players connected;
-- see zombie.GameTime.isGamePaused) fires OnTickEvenPaused instead, never
-- both. Without the second registration, the entire bridge (every op, not
-- just one) goes completely inert on any server running PauseEmpty=true
-- whenever it's empty: no event ever calls onTick, so a request just sits
-- in RconDataBridge_Request forever with no response and no error, and a
-- client sees a silent timeout with nothing to diagnose.
function RDB_RequestPipeline.init() function RDB_RequestPipeline.init()
if initialized then if initialized then
return return
end end
initialized = true initialized = true
lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST) lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST)
Events.OnTick.Add(onTick) Events.OnTick.Add(onTick)
Events.OnTickEvenPaused.Add(onTick)
end end