Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rate limit Widget use of GiveOrder* #4301

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 127 additions & 3 deletions LuaUI/cache.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
-- Poisoning for Spring.* functions (caching, filtering, providing back compat)

-- luacheck: globals currentGameFrame

if not Spring.IsUserWriting then
Spring.IsUserWriting = function()
return false
Expand Down Expand Up @@ -110,13 +112,13 @@ end
function Spring.GetVisibleUnits(teamID, radius, Icons)
local index = buildIndex(teamID, radius, Icons)

local currentFrame = Spring.GetGameFrame() -- frame is necessary (invalidates visibility; units can die or disappear outta LoS)
-- frame is necessary (invalidates visibility; units can die or disappear outta LoS)
local now = Spring.GetTimer() -- frame is not sufficient (eg. you can move the screen while game is paused)

local visible = visibleUnits[index]
if visible then
local diff = Spring.DiffTimers(now, visible.time)
if diff < 0.05 and currentFrame == visible.frame then
if diff < 0.05 and currentGameFrame == visible.frame then
return visible.units
end
else
Expand All @@ -126,7 +128,7 @@ function Spring.GetVisibleUnits(teamID, radius, Icons)

local ret = GetVisibleUnits(teamID, radius, Icons)
visible.units = ret
visible.frame = currentFrame
visible.frame = currentGameFrame
visible.time = now

return ret
Expand All @@ -152,3 +154,125 @@ function Spring.SetCameraTarget(x, y, z, transTime)
return SetCameraTarget(x, y, z, transTime) --return new results
end
end

-- Rate limit network commands. This limit is per-widget.
-- - This is meant to stop people accidentally shooting themselves in the foot.
-- - This is meant to provide a warning for when a widget is being excessive.
-- - In particular, this is not meant to create an ironclad sandbox to curtail abusive actors. A widget staying under these thresholds does not necessarily imply that it is okay!
local FRAMES_PER_SECOND = Game.gameSpeed
local MAX_ORDERS_PER_SECOND = 110
local CIRCLE_BUFFER_SIZE = 15
local MAX_ORDERS_PER_BUFFER = MAX_ORDERS_PER_SECOND * CIRCLE_BUFFER_SIZE
local FUNCTIONS_TO_RATELIMIT = {
"GiveOrder",
"GiveOrderToUnit",
"GiveOrderToUnitArray",
"GiveOrderToUnitMap",
"GiveOrderArrayToUnitMap",
"GiveOrderArrayToUnitArray",
}

-- Mechanism for painless reversion via infra without a stable, in case something goes wrong and this starts culling something it shouldn't.
-- - 0: No throttling.
-- - 1: Warn, giving a one-time warning when commands would be dropped.
-- - 2: Enforce, dropping commands above the threshold.
-- In local skirmishes, this is 1 to permit destructive testing. ZKI is generally expected to send 2, or temporarily 0 if, for example, a stable forgot to add a widget to the whitelist in cawidgets.lua.
local BLOCK_MODE = Spring.GetModOptions().throttle_commands and tonumber(Spring.GetModOptions().throttle_commands) or 1

local spLog = Spring.Log

function PoisonWidget(widget, widgetName)
if BLOCK_MODE == 0 then return end
-- All GiveOrder* functions use the same circle buffer to count commands for throttling.
-- Tracks calls made over the last CIRCLE_BUFFER_SIZE seconds, grouped by second.
local lastWindow = math.floor(currentGameFrame / FRAMES_PER_SECOND)
local lastFrame = currentGameFrame
local currentWindowCalls = 0
local circleBuffer = {}
local circleBufferIndex = 1
for i=1,CIRCLE_BUFFER_SIZE do
esainane marked this conversation as resolved.
Show resolved Hide resolved
circleBuffer[i] = 0
end

-- Create a local Spring table that the widget will receive in its environment.
local realSpring = widget.Spring
local localSpring = {}
for k,v in pairs(realSpring) do
localSpring[k] = v
end

-- Display a warning when a widget is sending commands at an unsustainable rate.
local highestPercentageWarned = 0
local noEnforceWarningYet = true

-- Actually apply the throttle
for i = 1, #FUNCTIONS_TO_RATELIMIT do
local fname = FUNCTIONS_TO_RATELIMIT[i]
local realFunction = localSpring[fname]
local function warnExcess(percentage)
highestPercentageWarned = percentage
Spring.Echo(fname .. ' use from ' .. widgetName .. ' hit soft ratelimit.')
spLog(widgetName, LOG.ERROR, "Warning: Excessive command rate (" .. circleBuffer[circleBufferIndex] .. " in the current second) in " .. fname .. " from Widget " .. widgetName .. ". " .. percentage .. "% of permitted burst budget used! Commands will soon be dropped if it continues to send commands at this rate!")
spLog(widgetName, LOG.ERROR, "===== CUT HERE WHEN REPORTING =====")
spLog(widgetName, LOG.ERROR, "Command origin:")
spLog(widgetName, LOG.ERROR, debug.traceback())
spLog(widgetName, LOG.ERROR, "===== END CUT =====")
Spring.Echo('Command volume history over the past ten seconds (current index: ' .. circleBufferIndex .. '):')
Spring.Utilities.TableEcho(circleBuffer)
end
--local dbHit
-- Replace the function with a rate limiting throttler
localSpring[fname] = function(...)
--WG.Debug.Echo(fname .. ' use from ' .. widgetName .. ' being tracked for rate limiting.')
-- Check if the frame number and window need updating since the last call.
if currentGameFrame ~= lastFrame then
local currentWindow = math.floor(currentGameFrame / FRAMES_PER_SECOND)
if currentWindow ~= lastWindow then
-- If the window has moved, which may involve moving more than once space, discard old windows.
local delta = math.min(CIRCLE_BUFFER_SIZE, currentWindow - lastWindow)
for j=1,delta do
-- Any window representing actions made more than 10 seconds in the past is discarded.
circleBufferIndex = circleBufferIndex + 1
if circleBufferIndex > CIRCLE_BUFFER_SIZE then
circleBufferIndex = 1
end
-- The actions from any window so removed is subtracted from the current count of actions.
currentWindowCalls = currentWindowCalls - circleBuffer[circleBufferIndex]
circleBuffer[circleBufferIndex] = 0
end
lastWindow = currentWindow
end
lastFrame = currentGameFrame
end
-- Rate limited command increments happen before command blocking, so any malfunctioning widget *will* continue to be shut off when silenced. Fix your widgets!
circleBuffer[circleBufferIndex] = circleBuffer[circleBufferIndex] + 1
currentWindowCalls = currentWindowCalls + 1

-- Finally, check against our thresholds.
if currentWindowCalls > MAX_ORDERS_PER_BUFFER then
-- If the "burst" budget has been exceeded, no more commands from you until you calm down.
-- WG.Debug.Echo(fname .. ' use from ' .. widgetName .. ' hit hard ratelimit.')
-- if not dbHit then dbHit = WG.Debouncer:new(warnExcess, 60) end
-- dbHit(100)
if BLOCK_MODE == 2 then
spLog(widgetName, LOG.ERROR, "Rate limit exceeded in " .. fname .. " from Widget " .. widgetName .. ". Command dropped, update/fix your widgets!")
return
elseif noEnforceWarningYet and BLOCK_MODE == 1 then
spLog(widgetName, LOG.ERROR, "Rate limit exceeded in " .. fname .. " from Widget " .. widgetName .. ". Excessive bursts of commands will soon be dropped, update/fix your widgets!")
end
elseif circleBuffer[circleBufferIndex] > MAX_ORDERS_PER_SECOND then
-- If we're running an an unsustainable rate, give one off warnings at 20%, 50%, and 80% of the "burst" budget.
if highestPercentageWarned < 80 and currentWindowCalls * 1.25 > MAX_ORDERS_PER_BUFFER then
warnExcess(80)
elseif highestPercentageWarned < 50 and currentWindowCalls * 2 > MAX_ORDERS_PER_BUFFER then
warnExcess(50)
elseif highestPercentageWarned < 20 and currentWindowCalls * 5 > MAX_ORDERS_PER_BUFFER then
warnExcess(20)
end
end
-- A-OK
return realFunction(...)
end
end
widget.Spring = localSpring
end
13 changes: 13 additions & 0 deletions LuaUI/cawidgets.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ local vfsGame = vfs.GAME
WG = {}
Spring.Utilities = {}

currentGameFrame = -1

vfsInclude("LuaRules/Utilities/tablefunctions.lua" , nil, vfsGame)
vfsInclude("LuaRules/Utilities/versionCompare.lua" , nil, vfsGame)
vfsInclude("LuaRules/Utilities/unitStates.lua" , nil, vfsGame)
Expand Down Expand Up @@ -99,6 +101,11 @@ VFSMODE = VFSMODE or VFS.ZIP

local detailLevel = Spring.GetConfigInt("widgetDetailLevel", 3)

local widgetRateLimitWhitelist = {
["unit_start_state.lua"] = true, -- Can legitimately send large volumes of commands when units are lost/recevied via afk
["cmd_customformations2.lua"] = true, -- Commands are functionally direct from the user
}

--------------------------------------------------------------------------------

-- install bindings for TweakMode and the Widget Selector
Expand Down Expand Up @@ -548,6 +555,11 @@ function widgetHandler:LoadWidget(filename, _VFSMODE)
end

local widget = widgetHandler:NewWidget()

if not widgetRateLimitWhitelist[basename] then
PoisonWidget(widget, basename)
end

setfenv(chunk, widget)
local success, err = pcall(chunk)
if (not success) then
Expand Down Expand Up @@ -2051,6 +2063,7 @@ end


function widgetHandler:GameFrame(frameNum)
currentGameFrame = frameNum
for _, w in r_ipairs(self.GameFrameList) do
w:GameFrame(frameNum)
end
Expand Down