Skip to content

Commit

Permalink
Add support for per-prefix transmission throttling
Browse files Browse the repository at this point in the history
In patch 4.4.0 and 10.2.7 Blizzard have tightened the restrictions on
addon comms to add a per-prefix throttle across all chat types,
effectively restricting them to one message per second with a small
accrued burst capacity.

The SendAddonMessage APIs now return an enum result code which includes
information if this client-side throttle has been applied to a submitted
message. With it, we can now properly handle throttling in CTL and
avoid situations where addon messages would be dropped for exceeding it.

This PR takes into consideration the discussion on Discord and takes a
slightly different approach to the other open one by instead
implementing the concept of a "blocked" pipe.

A pipe enters the "blocked" state whenever a message at its head is
attempted to be sent off, and a throttle result code is returned from
the API.

When transitioning to this state, the pipe is removed from the
transmission ring of its parent priority and is instead placed into a
separate (and new) blocked ring. This prevents the despool logic from
seeing blocked pipes and pointlessly attempting to re-send on them.

Periodically - currently every second - the contents of the blocked
rings in each priority are reintegrated back into the transmission
rings, allowing us to attempt re-transmission of queued messages.

This approach does have a slight downside in that in some cases there
may be additional transmission latency. Specifically, if we attempt to
send a message and the API blocks it due to throttling then there's a
chance that had we waited one game tick longer the prefix would have
permitted a transmission. However as the queue is now marked as blocked,
the transmission is delayed by potentially a whole second until the
integration timer ticks.

This could be alleviated somewhat if we either lowered the integration
period of blocked pipes from 1 second to something a bit lower, or
possibly just by outright removing it.

If the timer were removed then it's worth noting that the reintegration
of blocked pipes puts them at the _end_ of the transmission ring; which
conveniently means that unblocked pipes are always prioritized. As such
it may not be a bad idea to just forego the timer and let this implicit
sorting take the reins.

Aside from prefix throttling, there's a few other small changes in this
PR that were additionally discussed;

- User-supplied callbacks are now supplied an accurate 'didSend'
  parameter that will be false if the API returns a non-throttle-related
  error code.

- User-supplied callbacks are additionally now supplied the new result
  code as a third parameter. For Classic Era, we synthesize one from a
  subset of the enum values based off the boolean result that the API
  will still be providing there for now.

- User-supplied callbacks no longer let errors blow things up in an
  uncontrolled manner by being subject to securecall wrapping.

- Compatibility with the pre-8.0 *global* SendAddonMessage API was
  removed as it's no longer needed.
  • Loading branch information
Meorawr committed May 2, 2024
1 parent 7bb5022 commit 1824e8b
Showing 1 changed file with 113 additions and 32 deletions.
145 changes: 113 additions & 32 deletions AceComm-3.0/ChatThrottleLib.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
-- LICENSE: ChatThrottleLib is released into the Public Domain
--

local CTL_VERSION = 24
local CTL_VERSION = 25

local _G = _G

Expand Down Expand Up @@ -115,6 +115,27 @@ function Ring:Remove(obj)
end
end

function Ring:Link(other) -- Move and append all contents of another ring to this ring
if not self.pos then
-- This ring is empty, so just transfer ownership.
self.pos = other.pos
other.pos = nil
elseif other.pos then
-- Link the other ring to the back of this one.
local a = self.pos
local b = self.pos.prev
local x = other.pos
local y = other.pos.prev

a.prev = y -- My first points to their last...
y.next = a -- ...and their last to my first.
b.next = x -- My last points to their first...
x.prev = b -- ...and their first to my last.

other.pos = nil
end
end



-----------------------------------------------------------------------
Expand Down Expand Up @@ -179,6 +200,13 @@ function ChatThrottleLib:Init()
self.Prio["BULK"] = { ByName = {}, Ring = Ring:New(), avail = 0 }
end

if not self.BlockedQueuesDelay then
-- v25: Add blocked queues to rings to handle new client throttles.
for _, Prio in pairs(self.Prio) do
Prio.Blocked = Ring:New()
end
end

-- v4: total send counters per priority
for _, Prio in pairs(self.Prio) do
Prio.nTotalSent = Prio.nTotalSent or 0
Expand All @@ -201,6 +229,7 @@ function ChatThrottleLib:Init()
self.Frame:SetScript("OnEvent", self.OnEvent) -- v11: Monitor P_E_W so we can throttle hard for a few seconds
self.Frame:RegisterEvent("PLAYER_ENTERING_WORLD")
self.OnUpdateDelay = 0
self.BlockedQueuesDelay = 0
self.LastAvailUpdate = GetTime()
self.HardThrottlingBeginTime = GetTime() -- v11: Throttle hard for a few seconds after startup

Expand Down Expand Up @@ -292,38 +321,78 @@ end
-- - ... made up of N "Pipe"s (1 for each destination/pipename)
-- - and each pipe contains messages

local SendAddonMessageResult = Enum.SendAddonMessageResult or {
Success = 0,
AddonMessageThrottle = 3,
NotInGroup = 5,
ChannelThrottle = 8,
GeneralError = 9,
}

local function MapToSendResult(result)
if result == true then
result = SendAddonMessageResult.Success
elseif result == false then
result = SendAddonMessageResult.GeneralError
end

return result
end

local function WasMessageThrottled(result)
return result == SendAddonMessageResult.AddonMessageThrottle
or result == SendAddonMessageResult.ChannelThrottle
end

function ChatThrottleLib:Despool(Prio)
local ring = Prio.Ring
while ring.pos and Prio.avail > ring.pos[1].nSize do
local msg = table_remove(ring.pos, 1)
if not ring.pos[1] then -- did we remove last msg in this pipe?
local pipe = Prio.Ring.pos
Prio.Ring:Remove(pipe)
Prio.ByName[pipe.name] = nil
DelPipe(pipe)
else
Prio.Ring.pos = Prio.Ring.pos.next
end
local didSend=false
local pipe = ring.pos
local msg = pipe[1]
local sendResult
local lowerDest = strlower(msg[3] or "")

if lowerDest == "raid" and not UnitInRaid("player") then
-- do nothing
sendResult = SendAddonMessageResult.NotInGroup
elseif lowerDest == "party" and not UnitInParty("player") then
-- do nothing
sendResult = SendAddonMessageResult.NotInGroup
else
Prio.avail = Prio.avail - msg.nSize
bMyTraffic = true
msg.f(unpack(msg, 1, msg.n))
sendResult = MapToSendResult(select(-1, true, msg.f(unpack(msg, 1, msg.n))))
bMyTraffic = false
Prio.nTotalSent = Prio.nTotalSent + msg.nSize
DelMsg(msg)
didSend = true
end
-- notify caller of delivery (even if we didn't send it)
if msg.callbackFn then
msg.callbackFn (msg.callbackArg, didSend)

if WasMessageThrottled(sendResult) then
-- Message was throttled; move the pipe into the blocked ring.
Prio.Ring:Remove(pipe)
Prio.Blocked:Add(pipe)
else
-- Dequeue message after submission.
table_remove(pipe, 1)
DelMsg(msg)

if not pipe[1] then -- did we remove last msg in this pipe?
Prio.Ring:Remove(pipe)
Prio.ByName[pipe.name] = nil
DelPipe(pipe)
else
pipe.pos = pipe.pos.next
end

-- Update bandwidth counters on successful sends.
local didSend = (sendResult == SendAddonMessageResult.Success)
if didSend then
Prio.avail = Prio.avail - msg.nSize
Prio.nTotalSent = Prio.nTotalSent + msg.nSize
end

-- Notify caller of delivery.
if msg.callbackFn then
securecallfunction(msg.callbackFn, msg.callbackArg, didSend, sendResult)
end
end
-- USER CALLBACK MAY ERROR
end
end

Expand All @@ -342,6 +411,7 @@ function ChatThrottleLib.OnUpdate(this,delay)
local self = ChatThrottleLib

self.OnUpdateDelay = self.OnUpdateDelay + delay
self.BlockedQueuesDelay = self.BlockedQueuesDelay + delay
if self.OnUpdateDelay < 0.08 then
return
end
Expand All @@ -353,6 +423,15 @@ function ChatThrottleLib.OnUpdate(this,delay)
return -- argh. some bastard is spewing stuff past the lib. just bail early to save cpu.
end

-- Integrate blocked queues back into their rings periodically.
if self.BlockedQueuesDelay >= 1 then
for _, Prio in pairs(self.Prio) do
Prio.Ring:Link(Prio.Blocked)
end

self.BlockedQueuesDelay = 0
end

-- See how many of our priorities have queued messages (we only have 3, don't worry about the loop)
local n = 0
for prioname,Prio in pairs(self.Prio) do
Expand Down Expand Up @@ -435,7 +514,8 @@ function ChatThrottleLib:SendChatMessage(prio, prefix, text, chattype, languag
bMyTraffic = false
self.Prio[prio].nTotalSent = self.Prio[prio].nTotalSent + nSize
if callbackFn then
callbackFn (callbackArg, true)
-- We have no success/failure information here, and so may lie.
securecallfunction(callbackFn, callbackArg, true, SendAddonMessageResult.Success)
end
-- USER CALLBACK MAY ERROR
return
Expand Down Expand Up @@ -484,23 +564,24 @@ function ChatThrottleLib:SendAddonMessage(prio, prefix, text, chattype, target,
if not self.bQueueing and nSize < self:UpdateAvail() then
self.avail = self.avail - nSize
bMyTraffic = true
if _G.C_ChatInfo then
_G.C_ChatInfo.SendAddonMessage(prefix, text, chattype, target)
else
_G.SendAddonMessage(prefix, text, chattype, target)
end
local sendResult = MapToSendResult(select(-1, _G.C_ChatInfo.SendAddonMessage(prefix, text, chattype, target)))
bMyTraffic = false
self.Prio[prio].nTotalSent = self.Prio[prio].nTotalSent + nSize
if callbackFn then
callbackFn (callbackArg, true)

if not WasMessageThrottled(sendResult) then
local didSend = (sendResult == SendAddonMessageResult.Success)
self.Prio[prio].nTotalSent = self.Prio[prio].nTotalSent + nSize

if callbackFn then
securecallfunction(callbackFn, callbackArg, didSend, sendResult)
end

return
end
-- USER CALLBACK MAY ERROR
return
end

-- Message needs to be queued
local msg = NewMsg()
msg.f = _G.C_ChatInfo and _G.C_ChatInfo.SendAddonMessage or _G.SendAddonMessage
msg.f = _G.C_ChatInfo.SendAddonMessage
msg[1] = prefix
msg[2] = text
msg[3] = chattype
Expand Down

0 comments on commit 1824e8b

Please sign in to comment.