83 KiB
diff --git a/!!!!GentleGC_Memory/!!!!GentleGC_Memory.toc b/!!!!GentleGC_Memory/!!!!GentleGC_Memory.toc new file mode 100644 index 0000000..8e8b17c --- /dev/null +++ b/!!!!GentleGC_Memory/!!!!GentleGC_Memory.toc @@ -0,0 +1,8 @@ +## Interface: 11200 +## Title: Gentle GC - Memory +## Notes: Measures how much memory each addon keeps, at the loading screen. Results: /ggc addons (needs Gentle GC) +## Author: GentleGC contributors +## Version: 2.5 +## SavedVariables: GentleGCMemoryDB + +GentleGC_Memory.lua diff --git a/!!!!GentleGC_Memory/GentleGC_Memory.lua b/!!!!GentleGC_Memory/GentleGC_Memory.lua new file mode 100644 index 0000000..0535157 --- /dev/null +++ b/!!!!GentleGC_Memory/GentleGC_Memory.lua @@ -0,0 +1,118 @@ +--[[
- Gentle GC - Memory: how much memory each addon keeps.
- The 1.12 client has no per-addon memory numbers (GetAddOnMemoryUsage came
- in 2.x); gcinfo() only gives the size of the whole Lua heap. So this addon
- loads before the others (the "!!!!" sorts first) and, each time another
- addon finishes loading, runs a full collection and reads gcinfo(). The rise
- since the previous addon is what that addon kept: its code, its data and
- its saved variables. Same approach as AddonMemory by Salahaja.
- It only measures at the loading screen, where the extra collections can't
- be seen, and only when it's useful:
-
- the first login after it's installed
-
- when the set of enabled addons has changed since the last measurement
-
- the next /reload after /ggc addons measure
- Any other time it does nothing. Gentle GC reads the results (/ggc addons). +--]]
+local ME = "!!!!GentleGC_Memory" + +local M = { measuring = false, justMeasured = false } +GentleGCMemory = M + +-- Our own reference, taken before Gentle GC wraps the global, so these +-- measuring collections aren't reported as another addon's. +local collect = collectgarbage + +local run, last +local frame = CreateFrame("Frame") + +local function Live()
- collect()
- return (gcinfo()) +end
+-- The enabled addons, as one string: a change means a new measurement. +local function Signature()
- local names = {}
- for i = 1, GetNumAddOns() do
- local name, _, _, enabled = GetAddOnInfo(i)
- if name and enabled then table.insert(names, name) end
- end
- table.sort(names)
- return table.concat(names, ",") +end
+local function Start(db)
- local sig = Signature()
- local reason
- if db.measureNext then
- reason = "requested"
- elseif not db.measured then
- reason = "first login"
- elseif db.signature ~= sig then
- reason = "addons changed"
- end
- db.measureNext = nil
- if not reason then return end
- M.measuring = true
- run = {
- when = date and date("%Y-%m-%d %H:%M") or "?",
- reason = reason,
- base = Live(),
- before = {},
- rows = {},
- stages = {},
- }
- last = run.base
- -- anything that loaded before us is inside the base number; name it
- for i = 1, GetNumAddOns() do
- local name = GetAddOnInfo(i)
- if name and name ~= ME and IsAddOnLoaded(name) then table.insert(run.before, name) end
- end
- db.signature = sig +end
+local function Record(list, label)
- local kb = Live()
- table.insert(list, { label, kb - last })
- last = kb +end
+local function Finish()
- local total = 0
- for i = 1, table.getn(run.rows) do total = total + run.rows[i][2] end
- run.total = total
- run.after = last
- GentleGCMemoryDB.measured = run
- M.measuring = false
- M.justMeasured = true
- run = nil +end
+frame:RegisterEvent("ADDON_LOADED") +frame:RegisterEvent("PLAYER_LOGIN") +frame:RegisterEvent("PLAYER_ENTERING_WORLD") +frame:SetScript("OnEvent", function()
- if event == "ADDON_LOADED" then
- if arg1 == ME then
-
if type(GentleGCMemoryDB) ~= "table" then GentleGCMemoryDB = {} end -
Start(GentleGCMemoryDB) - elseif M.measuring then
-
-- this addon's files and saved variables; setup it does in its own -
-- ADDON_LOADED handler runs after ours and lands on the next addon -
Record(run.rows, arg1) - end
- elseif event == "PLAYER_LOGIN" then
- if M.measuring then Record(run.stages, "after the last addon loaded (setup, saved variables)") end
- elseif event == "PLAYER_ENTERING_WORLD" then
- if M.measuring then
-
Record(run.stages, "addons setting up at login") -
Finish() - end
- -- load-on-demand addons come later, in play: never measured
- frame:UnregisterAllEvents()
- end +end) diff --git a/GentleGC.lua b/GentleGC.lua new file mode 100644 index 0000000..b098928 --- /dev/null +++ b/GentleGC.lua @@ -0,0 +1,1015 @@ +--[[
- GentleGC - unified edition (for WoW 1.18.1 clients, Lua 5.0)
- Lua 5.0 has no incremental collector: every collection is a full
- stop-the-world mark + sweep, and the automatic collector fires whenever the
- heap reaches its threshold. Stock Lua 5.0 sets that threshold to 2x live
- memory after every collection, but the 1.12 client replaces that rule
- (luaM_adjustGC): threshold = live + max(min(carry-over headroom, allocator
- pool capacity), live/4), so at least 1.25x and variable. GentleGC measures
- the ratio from full collections and plans with the lowest one seen.
- We can't make a single collection cheaper, but we can choose WHEN it happens:
-
- collect at moments the player won't feel (loading screens, AFK, NPC
-
windows, right after combat) -- from jrc13245's fork -
- run from event handlers, keeping the OnUpdate path only as a fallback
-
-- from jrc13245's fork -
- keep reporting on its own timer, separate from collecting
-
-- from TheRealFayz's fork - and new in this version:
-
- skip collections that would free little (a pause costs about the same
-
whether it frees 1 MB or 100 MB, because marking live data dominates) -
- collect pre-emptively out of combat when the heap is close to the
-
threshold, so the automatic collector doesn't fire on the next pull -
- raise the threshold for the duration of combat so the automatic
-
collector is pushed to after the fight, then restore it -
- saved settings and a /ggc slash command
- optional, used only when the ClassicAPI client mod is installed:
-
- collect when you've been standing still for a few seconds (GetUnitSpeed)
-
- run the once-a-second check from C_Timer instead of a frame OnUpdate
- when collections are slow (default: averaging 100 ms or more):
-
- careful mode: no stand-still or idle collections, only loading screens,
-
death, taxi, NPC windows, AFK, after combat, and pressure - diagnostics:
-
- per-addon memory, measured at the loading screen by the optional
-
!!!!GentleGC_Memory companion (it has to load before other addons) -
- collections run by other addons are timed and attributed by name
-
- garbage creation rate (MB/s), with a warning when it is very high
-
- /ggc hitch: report frames that take too long, and whether a garbage
-
collection (ours, the game's, or another addon's) happened in them
+]] + +local ADDON = "GentleGC" + +-- The collectgarbage GentleGC itself uses. The global is wrapped further down +-- so collections run by other addons can be attributed; our own calls go +-- straight to the function that was there when we loaded. +local rawCollect = collectgarbage + +local DEFAULTS = {
- debug = 0, -- 0 off, 1 periodic summary, 2 also every collection
- minInterval = 30, -- seconds: never collect more often than this
- maxInterval = 300, -- seconds: out-of-combat fallback collection
- minGarbageMB = 4, -- skip a collection that would free less than this...
- minGarbagePct = 15, -- ...or less than this % of live memory (the larger wins)
- pressureRoomPct = 70, -- out of combat: collect early once garbage fills this % of the room
-
-- between live memory and the auto-GC threshold (70 = 85% of a 2x threshold) - combatHeadroomMB = 32, -- extra room before auto-GC during combat (0 = off)
- reportInterval = 300, -- seconds between debug summaries
- stillSeconds = 3, -- ClassicAPI only: collect after standing still this long (0 = off)
- hitchMs = 0, -- freeze detector: report frames longer than this (0 = off)
- slowMs = 100, -- careful mode when collections average this long (0 = never)
- allocWarnMB = 10, -- warn when garbage is created faster than this, MB/s (0 = off) +}
+local db = {} + +local function ApplyDefaults()
- if type(GentleGCDB) ~= "table" then GentleGCDB = {} end
- db = GentleGCDB
- for k, v in pairs(DEFAULTS) do
- if db[k] == nil then db[k] = v end
- end
- db.pressurePct = nil -- replaced by pressureRoomPct in 2.3 +end +ApplyDefaults() -- usable immediately; re-run once saved settings load
+local frame = CreateFrame("Frame") +local inCombat = false +local lastGC = 0 -- GetTime() of our last collection +local liveKB = 0 -- heap size right after the last collection (live-data estimate) +local savedThreshold = nil -- threshold before we raised it for combat +local knownThreshold = 0 -- last threshold we saw or set (detects automatic GCs) +local ratioLast, ratioMin = nil, nil -- threshold / live after full collections (last, lowest) +local lastUsed = 0 -- heap at the last sample (per frame) +local allocTotalKB, allocPeriodKB, allocWindowKB = 0, 0, 0 +local allocRate = 0 -- KB/s, ~30 s moving average +local nextAllocWarn = 0 +local pauseAvg = nil -- ms, moving average of our own collections +local careful = false +local extByName = {} -- session: collections run by other addons, by addon name +local extSeq, extMs, extName = 0, 0, nil -- last external collection (freeze detector) +do
- local used, threshold = gcinfo()
- -- before any collection we can only assume the stock 2x ratio; the first
- -- collection (usually the login loading screen) measures the real one
- liveKB = math.min(used, math.floor(threshold / 2))
- knownThreshold = threshold
- lastUsed = used +end +local tick, reportElapsed = 0, 0 +local hasSpeed, hasTicker = false, false -- ClassicAPI features, detected at load +local stillFor, lastCheck = 0, 0 +local loadTime = 0 -- the idle fallback counts from login +-- for the freeze detector: counters, because GetTime() only ticks every ~15ms +local ownCollectSeq, ownCollectMs, ownCollectReason = 0, 0, nil +local ownThresholdSeq = 0 -- bumped when we change the threshold without collecting +local EnterCombatThreshold -- defined below, used by Sync() +local StartScheduler -- defined below, called on ADDON_LOADED +local HitchLoading, HitchEnable -- freeze detector, defined below +local ShowAddonSummary -- per-addon memory, defined below
+local function NewStats() return { count = 0, freed = 0, skipped = 0, ms = 0, worst = 0, auto = 0, autoCombat = 0, ext = 0, extCombat = 0 } end +local period, total = NewStats(), NewStats() + +--------------------------------------------------------------------------- +-- helpers +--------------------------------------------------------------------------- + +local function Msg(text)
- DEFAULT_CHAT_FRAME:AddMessage("|cff88ff88GentleGC|r " .. text) +end
+local function DebugLevel()
- -- old builds used the global
ggc_debug; still honour it - if ggc_debug then return 2 end
- return db.debug or 0 +end
+local function MB(kb) return kb / 1024 end + +local function ResetStats(s)
- s.count, s.freed, s.skipped, s.ms, s.worst, s.auto, s.autoCombat, s.ext, s.extCombat = 0, 0, 0, 0, 0, 0, 0, 0, 0 +end
+-- Wall-clock timing. debugprofilestop() has ms precision but resets a timer +-- other addons may use, so it is only touched while debugging or detecting freezes. +local function StartTimer()
- if (DebugLevel() > 0 or db.hitchMs > 0) and debugprofilestart then
- debugprofilestart()
- return nil
- end
- return GetTime() +end
+local function StopTimer(t0)
- if t0 == nil then return debugprofilestop() end
- return (GetTime() - t0) * 1000 +end
+-- How much would a collection free? Estimate: everything allocated since +-- the last collection beyond the live set we measured then. +local function GarbageKB()
- return gcinfo() - liveKB +end
+-- Allocation accounting: heap growth between samples is new allocation. +local function AddAlloc(kb)
- if kb <= 0 then return end
- allocTotalKB = allocTotalKB + kb
- allocPeriodKB = allocPeriodKB + kb
- allocWindowKB = allocWindowKB + kb +end
+local function Account(used)
- AddAlloc(used - lastUsed)
- lastUsed = used +end
+-- Record a full collection's threshold ratio. The client's rule makes it +-- vary (at least 1.25x), so planning uses the lowest ratio seen. +local function LearnRatio(after, threshold)
- if after <= 1024 then return end
- local f = threshold / after
- if f <= 1.05 or f >= 10 then return end
- local first = ratioMin == nil
- ratioLast = f
- if not ratioMin or f < ratioMin then ratioMin = f end
- if first and DebugLevel() >= 1 then
- Msg(format("collector threshold measured: %.2fx live memory", f))
- end +end
+local function CountExternal(name, ms, freed)
- for _, s in ipairs({ period, total }) do
- s.ext = s.ext + 1
- if inCombat then s.extCombat = s.extCombat + 1 end
- end
- name = name or "unknown addon"
- extByName[name] = (extByName[name] or 0) + 1
- extSeq, extMs, extName = extSeq + 1, ms, name
- if DebugLevel() >= 2 then
- Msg(format("collection by %s%s%s", name,
-
freed and format(": freed %.1fmb", MB(freed)) or "", -
ms and format(" in %dms", ms) or "")) - end +end
+-- After someone else's collection the collector has set a fresh threshold, +-- and any raised combat threshold is gone: in combat, grant the headroom again. +local function AfterForeignCollection()
- savedThreshold = nil
- if inCombat then EnterCombatThreshold() end +end
+-- Detect collections we didn't run and thresholds we didn't set. A heap +-- drop means a collection; a changed threshold without a drop means someone +-- set it with collectgarbage(n). For a collection: the game's +-- collector only fires when the heap reaches the threshold, so if the heap +-- was well below it one frame earlier, another addon ran it. +local function Sync()
- local used, threshold = gcinfo()
- -- the heap only shrinks through a collection, which may leave the
- -- threshold unchanged if live memory happens to be the same as before
- if threshold == knownThreshold and used >= lastUsed - 1024 then Account(used); return end
- local oldThreshold = knownThreshold
- knownThreshold = threshold
- if oldThreshold == 0 then Account(used); return end
- if used < lastUsed - 1024 and GentleGCMemory and GentleGCMemory.measuring then
- -- the memory companion collecting between addons at the loading screen
- liveKB = used
- lastUsed = used
- return
- end
- if used < lastUsed - 1024 then
- local est = used
- if ratioMin then est = math.min(used, math.floor(threshold / ratioMin)) end
- liveKB = est
- if lastUsed < oldThreshold * 0.9 then
-
AddAlloc(used - est) -
CountExternal(nil, nil, lastUsed - est) - else
-
AddAlloc(oldThreshold - lastUsed) -
AddAlloc(used - est) -
for _, s in ipairs({ period, total }) do -
s.auto = s.auto + 1 -
if inCombat then s.autoCombat = s.autoCombat + 1 end -
end -
if DebugLevel() >= 2 then -
Msg(format("automatic GC detected%s (live ~%.1fmb)", inCombat and " in combat" or "", MB(liveKB))) -
end - end
- lastUsed = used
- AfterForeignCollection()
- else
- -- threshold set by someone without collecting; live memory is unchanged
- Account(used)
- if inCombat then EnterCombatThreshold() end
- end +end
+-- The garbage share at which the automatic collector fires is (ratio - 1): +-- 25% of live on this client, 100% on stock Lua. Never demand more than half. +local function EffectiveGarbagePct()
- return math.min(db.minGarbagePct, ((ratioMin or 2) - 1) * 50) +end
+local function WorthCollecting()
- Sync()
- local need = math.max(db.minGarbageMB * 1024, liveKB * EffectiveGarbagePct() / 100)
- return GarbageKB() >= need +end
+-- Heap size at which a pressure collection runs: a share of the room between +-- live memory and the threshold, so it works for any ratio. +local function PressurePointKB(threshold)
- local room = threshold - liveKB
- if room <= 0 then return threshold * 0.9 end
- return liveKB + room * db.pressureRoomPct / 100 +end
+-- Careful mode: collections are slow enough to be seen during play. +local function UpdateCareful(ms)
- pauseAvg = pauseAvg and (pauseAvg * 0.7 + ms * 0.3) or ms
- local was = careful
- if db.slowMs <= 0 then
- careful = false
- elseif careful then
- careful = pauseAvg >= db.slowMs * 0.75
- else
- careful = pauseAvg >= db.slowMs
- end
- if careful ~= was and DebugLevel() >= 1 then
- if careful then
-
Msg(format("collections average %dms: careful mode - no stand-still or idle collections", pauseAvg)) - else
-
Msg(format("collections average %dms: normal mode", pauseAvg)) - end
- end +end
+--------------------------------------------------------------------------- +-- collection +--------------------------------------------------------------------------- + +local function Collect(reason)
- Sync()
- local before, thresholdBefore = gcinfo()
- local t0 = StartTimer()
- rawCollect() -- full collection; the client sets a fresh threshold
- local ms = StopTimer(t0)
- local after, threshold = gcinfo()
- if threshold == thresholdBefore and after >= before - 256 then
- -- nothing happened: an addon that wraps collectgarbage blocked the call
- lastGC = GetTime()
- if DebugLevel() >= 1 then
-
Msg(reason .. ": collection did not run (another addon blocks collectgarbage)") - end
- return
- end
- liveKB = after
- lastUsed = after
- LearnRatio(after, threshold)
- UpdateCareful(ms)
- knownThreshold = threshold
- lastGC = GetTime()
- savedThreshold = nil -- collectgarbage() already set a fresh threshold
- ownCollectSeq, ownCollectMs, ownCollectReason = ownCollectSeq + 1, ms, reason
- local freed = before - after
- for _, s in ipairs({ period, total }) do
- s.count = s.count + 1
- s.freed = s.freed + freed
- s.ms = s.ms + ms
- if ms > s.worst then s.worst = ms end
- end
- if DebugLevel() >= 2 then
- Msg(format("%s: %.1fmb -> %.1fmb (freed %.1fmb, next auto-GC at %.1fmb) in %dms",
-
reason, MB(before), MB(after), MB(freed), MB(threshold), ms)) - end +end
+-- reason: label for debug output +-- ignoreInterval: true for moments where a pause is free (loading screen) +-- or urgent (heap about to trigger the automatic collector) +-- silent: don't count a skip (periodic checks retry every second) +local function TryCollect(reason, ignoreInterval, silent)
- if inCombat then return end
- if not ignoreInterval and GetTime() - lastGC < db.minInterval then return end
- if not WorthCollecting() then
- if silent then return end
- period.skipped = period.skipped + 1
- total.skipped = total.skipped + 1
- return
- end
- Collect(reason) +end
+--------------------------------------------------------------------------- +-- combat: push the automatic collector past the end of the fight +--------------------------------------------------------------------------- + +EnterCombatThreshold = function()
- if db.combatHeadroomMB <= 0 then return end
- local used, threshold = gcinfo()
- local want = used + db.combatHeadroomMB * 1024
- if want > threshold then
- savedThreshold = savedThreshold or threshold
- rawCollect(want) -- raising the threshold never triggers a collection
- local _, t = gcinfo()
- knownThreshold = t
- ownThresholdSeq = ownThresholdSeq + 1
- end +end
+local function EnterCombat()
- Sync()
- inCombat = true
- EnterCombatThreshold() +end
+local function LeaveCombat()
- Sync()
- inCombat = false
- local used = gcinfo()
- if savedThreshold and used >= savedThreshold then
- -- we deferred a collection that was due during the fight: do it now
- Collect("post-combat (deferred)")
- return
- end
- TryCollect("post-combat")
- if savedThreshold then
- rawCollect(savedThreshold)
- savedThreshold = nil
- local _, t = gcinfo()
- knownThreshold = t
- ownThresholdSeq = ownThresholdSeq + 1
- end +end
+--------------------------------------------------------------------------- +-- other addons' collections +--------------------------------------------------------------------------- + +local gfind = string.gfind or string.gmatch + +-- Name the addon that called us, from the call stack (Interface\AddOns\Name...). +local function CallerAddon()
- if not debugstack then return nil end
- local ok, stack = pcall(debugstack)
- if not ok or type(stack) ~= "string" then return nil end
- for name in gfind(stack, "[Aa][Dd][Dd][Oo][Nn][Ss]\/[\/]") do
- if name ~= ADDON then return name end
- end
- return nil +end
+-- Wraps the global so a collection another addon runs is timed, attributed, +-- and doesn't throw off our live-memory estimate. Queries ("count" and other +-- string options) pass straight through without allocating. +local function WrappedCollect(a, b)
- if type(a) == "string" and a ~= "collect" then return rawCollect(a, b) end
- Sync()
- local before = gcinfo()
- local t0 = GetTime()
- local r = rawCollect(a, b)
- local ms = (GetTime() - t0) * 1000
- local after, threshold = gcinfo()
- local full = a == nil or a == 0 or a == "collect"
- if after < before - 256 or (full and threshold ~= knownThreshold) then
- -- a full collection (or a threshold set below the heap, which collects)
- liveKB = after
- lastUsed = after
- knownThreshold = threshold
- LearnRatio(after, threshold)
- CountExternal(CallerAddon(), ms, before - after)
- AfterForeignCollection()
- elseif threshold ~= knownThreshold then
- knownThreshold = threshold
- Account(after)
- if inCombat then EnterCombatThreshold() end
- end
- return r +end
+if type(rawCollect) == "function" then
- collectgarbage = WrappedCollect +end
+-- Per-frame heap sample: one gcinfo() call, no allocation. It keeps lastUsed +-- one frame old, which is what tells the game's collector apart from a +-- collection run by an addon we couldn't wrap. +local sampleFrame -- created on ADDON_LOADED +local function SampleOnUpdate()
- local used, threshold = gcinfo()
- if threshold ~= knownThreshold or used < lastUsed - 1024 then
- Sync()
- else
- AddAlloc(used - lastUsed)
- lastUsed = used
- end +end
+--------------------------------------------------------------------------- +-- events +--------------------------------------------------------------------------- + +-- Moments when a short stall goes unnoticed. Window-close events and +-- frequent sub-zone events are left out: closing means you're about to move, +-- and ZONE_CHANGED/INDOORS fire constantly (and in combat). +local SAFE_EVENTS = {
- PLAYER_DEAD = "dead",
- PLAYER_ALIVE = "released",
- PLAYER_UNGHOST = "resurrected",
- PLAYER_CAMPING = "logout",
- PLAYER_CONTROL_LOST = "flight/control lost",
- ZONE_CHANGED_NEW_AREA = "new zone",
- MERCHANT_SHOW = "merchant",
- BANKFRAME_OPENED = "bank",
- MAIL_SHOW = "mailbox",
- AUCTION_HOUSE_SHOW = "auction house",
- TRAINER_SHOW = "trainer",
- TAXIMAP_OPENED = "flight master", +}
+frame:RegisterEvent("ADDON_LOADED") +frame:RegisterEvent("PLAYER_LEAVING_WORLD") -- loading screen is about to show +frame:RegisterEvent("PLAYER_ENTERING_WORLD") +frame:RegisterEvent("PLAYER_REGEN_DISABLED") +frame:RegisterEvent("PLAYER_REGEN_ENABLED") +frame:RegisterEvent("PLAYER_FLAGS_CHANGED") -- AFK +for e in pairs(SAFE_EVENTS) do frame:RegisterEvent(e) end + +frame:SetScript("OnEvent", function()
- if event == "ADDON_LOADED" then
- if arg1 ~= ADDON then return end
- ApplyDefaults()
- StartScheduler()
- HitchEnable()
- frame:UnregisterEvent("ADDON_LOADED")
- elseif event == "PLAYER_REGEN_DISABLED" then
- EnterCombat()
- elseif event == "PLAYER_REGEN_ENABLED" then
- LeaveCombat()
- elseif event == "PLAYER_LEAVING_WORLD" or event == "PLAYER_ENTERING_WORLD" then
- HitchLoading(event == "PLAYER_LEAVING_WORLD")
- if event == "PLAYER_ENTERING_WORLD" and GentleGCMemory and GentleGCMemory.justMeasured then
-
GentleGCMemory.justMeasured = false -
ShowAddonSummary() - end
- inCombat = UnitAffectingCombat("player") and true or false
- TryCollect("loading screen", true)
- elseif event == "PLAYER_FLAGS_CHANGED" then
- if UnitIsAFK("player") then TryCollect("afk") end
- elseif SAFE_EVENTS[event] then
- TryCollect(SAFE_EVENTS[event])
- end +end)
+-- Once-a-second check: auto-GC detection, pressure, stand-still, fallback, report. +local function Check(dt)
- reportElapsed = reportElapsed + dt
- Sync()
- -- garbage creation rate, ~30 s moving average
- if dt > 0 then
- local inst = allocWindowKB / dt
- allocWindowKB = 0
- allocRate = allocRate + (inst - allocRate) * math.min(1, dt / 30)
- end
- if inCombat then
- stillFor = 0
- else
- if hasSpeed then
-
local speed = GetUnitSpeed("player") -
local falling = IsFalling and IsFalling() -
if speed == 0 and not falling then stillFor = stillFor + dt else stillFor = 0 end - end
- local used, threshold = gcinfo()
- if used >= PressurePointKB(threshold) and GetTime() - lastGC >= 5 then
-
-- The automatic collector is about to fire anyway and would cost the -
-- same pause, so collect now, out of combat, without the garbage minimum. -
Collect("pressure") - elseif careful then
-
-- collections are slow enough to see: wait for a loading screen, NPC -
-- window, taxi, death, AFK or the end of combat (or pressure) - elseif hasSpeed and db.stillSeconds > 0 and stillFor >= db.stillSeconds then
-
TryCollect("standing still", false, true) - elseif GetTime() - math.max(lastGC, loadTime) >= db.maxInterval then
-
TryCollect("idle", false, true) - end
- local now = GetTime()
- if db.allocWarnMB > 0 and allocRate > db.allocWarnMB * 1024 and now >= nextAllocWarn
-
and now - loadTime > 60 then -
nextAllocWarn = now + 600 -
local room = threshold - liveKB -
Msg(format("garbage is being created at %.1f MB/s (last 30 s): at this rate the heap fills up every ~%ds. " -
.. "/ggc hitch shows which events the memory jumps follow.", -
MB(allocRate), room > 0 and room / allocRate or 0)) - end
- end
- if reportElapsed >= db.reportInterval then
- if DebugLevel() >= 1 and (period.count > 0 or period.skipped > 0 or period.auto > 0 or period.ext > 0) then
-
Msg(format("last %d min: %d collections (%d skipped), freed %.1fmb, avg %dms, worst %dms; %d automatic (%d in combat), %d by other addons; garbage %.1f MB/s; now %.1fmb", -
reportElapsed / 60, period.count, period.skipped, MB(period.freed), -
period.count > 0 and period.ms / period.count or 0, period.worst, -
period.auto, period.autoCombat, period.ext, MB(allocPeriodKB / reportElapsed), MB(gcinfo()))) - end
- allocPeriodKB = 0
- ResetStats(period)
- reportElapsed = 0
- end +end
+local function OnUpdate()
- tick = tick + arg1
- if tick < 1 then return end
- local dt = tick
- tick = 0
- Check(dt) +end
+-- C_Timer callbacks run once per frame from the world tick, outside the +-- frame OnUpdate walk. Without ClassicAPI, fall back to a frame OnUpdate. +StartScheduler = function()
- sampleFrame = sampleFrame or CreateFrame("Frame")
- sampleFrame:SetScript("OnUpdate", SampleOnUpdate)
- hasSpeed = type(GetUnitSpeed) == "function"
- hasTicker = type(C_Timer) == "table" and type(C_Timer.NewTicker) == "function"
- lastCheck = GetTime()
- loadTime = lastCheck
- if hasTicker then
- frame:SetScript("OnUpdate", nil)
- C_Timer.NewTicker(1, function()
-
local now = GetTime() -
local dt = now - lastCheck -
lastCheck = now -
Check(dt) - end)
- else
- frame:SetScript("OnUpdate", OnUpdate)
- end +end
+--------------------------------------------------------------------------- +-- freeze detector (/ggc hitch) +-- +-- Runs on its own frame's OnUpdate, only while enabled. Each frame it reads +-- the clock and gcinfo() (no allocation). When the gap since the previous +-- frame exceeds the limit, it reports what happened inside that gap: +-- * a GentleGC collection (with its measured duration) +-- * a collection we didn't run: the game's automatic GC, or another +-- addon calling collectgarbage() (detected by the heap shrinking or +-- the threshold changing) +-- * no collection at all: the freeze came from something else +-- plus how much Lua memory was allocated during it and which frame was +-- under the mouse (the window being dragged, usually). +-- +-- It also records every game event (RegisterAllEvents) into a fixed ring +-- buffer: name, first argument, time and Lua memory, assigning numbers and +-- existing values only, so nothing is allocated per event. A report lists +-- the events that fired during the freeze and where the time went: the +-- longest gap between them, and the event the biggest memory jump followed. +--------------------------------------------------------------------------- + +local hitchFrame = CreateFrame("Frame") +local hPrevTime, hPrevUsed, hPrevThreshold = 0, 0, 0 +local hPrevCollectSeq, hPrevThresholdSeq, hPrevExtSeq = 0, 0, 0 +local hLoading, hSkip = false, 0 +local hitchLog = {} -- last 10 freezes, newest last +local HITCH_LOG_SIZE = 10 + +-- event ring buffer +local EV_N = 64 +local evFrame = CreateFrame("Frame") +local evName, evArg, evTime, evMem = {}, {}, {}, {} +local evPos, evSeq = 0, 0 -- last written slot, events recorded so far +local hFrameStartSeq = 0 -- evSeq at the previous OnUpdate +local hasAllEvents = evFrame.RegisterAllEvents ~= nil + +local function EvOnEvent()
- evSeq = evSeq + 1
- evPos = evPos + 1
- if evPos > EV_N then evPos = 1 end
- evName[evPos] = event
- evArg[evPos] = arg1
- evTime[evPos] = GetTime()
- evMem[evPos] = gcinfo() +end
+local function ArgText(a)
- local t = type(a)
- if t == "string" then
- if string.len(a) > 24 then a = string.sub(a, 1, 24) .. "..." end
- return " (" .. a .. ")"
- elseif t == "number" then
- return " (" .. a .. ")"
- end
- return "" +end
+-- Describe the events recorded after event number startSeq. +-- frameStart/frameEnd: GetTime() at the two OnUpdates; memStart/memEnd: gcinfo() then. +local function EventReport(startSeq, frameStart, frameEnd, memStart, memEnd)
- if not hasAllEvents then return "" end
- local n = evSeq - startSeq
- if n <= 0 then return "; no game events fired during it" end
- local kept = n
- if kept > EV_N then kept = EV_N end
- -- slots oldest -> newest
- local first = evPos - kept + 1
- if first < 1 then first = first + EV_N end
- local counts, order = {}, {}
- local bestGap, bestAfter, bestBefore = -1, nil, nil
- local bestAlloc, bestAllocAfter = 0, nil
- local prevT, prevM, prevLabel = frameStart, memStart, nil
- if kept < n then prevT, prevM = nil, nil end -- start of the frame was lost
- local i = first
- for k = 1, kept do
- local name = evName[i]
- if not counts[name] then counts[name] = 0; table.insert(order, name) end
- counts[name] = counts[name] + 1
- local label = name .. ArgText(evArg[i])
- if prevT then
-
local g = evTime[i] - prevT -
if g > bestGap then bestGap, bestAfter, bestBefore = g, prevLabel, label end -
local a = evMem[i] - prevM -
if a > bestAlloc then bestAlloc, bestAllocAfter = a, prevLabel or "frame start" end - end
- prevT, prevM, prevLabel = evTime[i], evMem[i], label
- i = i + 1
- if i > EV_N then i = 1 end
- end
- local g = frameEnd - prevT
- if g > bestGap then bestGap, bestAfter, bestBefore = g, prevLabel, nil end
- local a = memEnd - prevM
- if a > bestAlloc then bestAlloc, bestAllocAfter = a, prevLabel end
- table.sort(order, function(x, y) return counts[x] > counts[y] end)
- local parts = {}
- for k = 1, math.min(4, table.getn(order)) do
- local nm = order[k]
- table.insert(parts, counts[nm] > 1 and (nm .. " x" .. counts[nm]) or nm)
- end
- if table.getn(order) > 4 then table.insert(parts, "...") end
- local text = format("; %d event%s: %s", n, n == 1 and "" or "s", table.concat(parts, ", "))
- if kept < n then text = text .. format(" (last %d kept)", kept) end
- local where
- if not bestAfter then
- where = "before the first event"
- elseif not bestBefore then
- where = "after " .. bestAfter .. ", the last event"
- else
- where = "after " .. bestAfter
- end
- text = text .. format("; longest gap %.2fs %s", bestGap, where)
- if bestAlloc > 1024 then
- text = text .. format("; %.1fmb allocated after %s", MB(bestAlloc), bestAllocAfter)
- end
- return text +end
+local function FocusName()
- if not GetMouseFocus then return nil end
- local f = GetMouseFocus()
- if not f or not f.GetName then return nil end
- local name = f:GetName()
- -- walk up to the first named ancestor for anonymous child frames
- local depth = 0
- while not name and f.GetParent and depth < 10 do
- f = f:GetParent()
- if not f or not f.GetName then break end
- name = f:GetName()
- depth = depth + 1
- end
- return name +end
+local function HitchReset()
- hPrevTime = GetTime()
- hPrevUsed, hPrevThreshold = gcinfo()
- hPrevCollectSeq, hPrevThresholdSeq, hPrevExtSeq = ownCollectSeq, ownThresholdSeq, extSeq
- hFrameStartSeq = evSeq +end
+local function HitchOnUpdate()
- local now = GetTime()
- local used, threshold = gcinfo()
- local gap = now - hPrevTime
- local prevTime, prevUsed, prevThreshold = hPrevTime, hPrevUsed, hPrevThreshold
- local startSeq = hFrameStartSeq
- local weCollected = ownCollectSeq ~= hPrevCollectSeq
- local weMovedThreshold = ownThresholdSeq ~= hPrevThresholdSeq
- local otherAddon = extSeq ~= hPrevExtSeq
- hPrevTime, hPrevUsed, hPrevThreshold = now, used, threshold
- hPrevCollectSeq, hPrevThresholdSeq, hPrevExtSeq = ownCollectSeq, ownThresholdSeq, extSeq
- hFrameStartSeq = evSeq
- if hLoading then return end
- if hSkip > 0 then hSkip = hSkip - 1; return end
- if gap * 1000 < db.hitchMs then return end
- local cause
- if weCollected then
- cause = format("GentleGC collection (%s) took %dms", ownCollectReason or "?", ownCollectMs)
- elseif otherAddon then
- cause = format("garbage collection run by %s%s", extName or "another addon",
-
extMs and format(" took %dms", extMs) or "") - elseif used < prevUsed - 256 or (threshold ~= prevThreshold and not weMovedThreshold) then
- cause = format("the game's automatic garbage collection: heap %.1fmb -> %.1fmb",
-
MB(prevUsed), MB(used)) - else
- cause = "no garbage collection - something else caused it"
- end
- local grew = used - prevUsed
- local alloc = ""
- if grew > 1024 then alloc = format("; %.1fmb of Lua memory allocated during it", MB(grew)) end
- local focus = FocusName()
- local where = focus and ("; mouse over " .. focus) or ""
- local combat = inCombat and " (in combat)" or ""
- local events = EventReport(startSeq, prevTime, now, prevUsed, used)
- local line = format("freeze %.2fs%s: %s%s%s%s", gap, combat, cause, alloc, where, events)
- Msg("|cffff8888" .. line .. "|r")
- table.insert(hitchLog, format("%s %s", date and date("%H:%M:%S") or format("%.0f", now), line))
- if table.getn(hitchLog) > HITCH_LOG_SIZE then table.remove(hitchLog, 1) end +end
+-- loading screens are long "frames" by design; ignore them +HitchLoading = function(leaving)
- hLoading = leaving
- if not leaving then
- hSkip = 3
- HitchReset()
- end +end
+HitchEnable = function()
- if db.hitchMs > 0 then
- HitchReset()
- hSkip = 1
- hitchFrame:SetScript("OnUpdate", HitchOnUpdate)
- if hasAllEvents then
-
evFrame:SetScript("OnEvent", EvOnEvent) -
evFrame:RegisterAllEvents() - end
- else
- hitchFrame:SetScript("OnUpdate", nil)
- if hasAllEvents then
-
evFrame:UnregisterAllEvents() -
evFrame:SetScript("OnEvent", nil) - end
- end +end
+--------------------------------------------------------------------------- +-- per-addon memory (measured by the !!!!GentleGC_Memory companion) +--------------------------------------------------------------------------- + +local COMPANION = "!!!!GentleGC_Memory" +local ADDONS_TOP = 15 + +local function Size(kb)
- if kb < 0 then return "-" .. Size(-kb) end
- if kb < 1024 then return format("%d KB", kb) end
- return format("%.1f MB", kb / 1024) +end
+local function Measurement()
- return type(GentleGCMemoryDB) == "table" and GentleGCMemoryDB.measured or nil +end
+local function SortedRows(m)
- local rows = {}
- for i = 1, table.getn(m.rows) do table.insert(rows, m.rows[i]) end
- table.sort(rows, function(a, b) return a[2] > b[2] end)
- return rows +end
+local function Biggest(m, n)
- local rows, parts = SortedRows(m), {}
- for i = 1, math.min(n, table.getn(rows)) do
- table.insert(parts, rows[i][1] .. " " .. Size(rows[i][2]))
- end
- return table.concat(parts, ", ") +end
+ShowAddonSummary = function()
- local m = Measurement()
- if not m then return end
- Msg(format("measured your addons at this loading screen: %s in %d addons. Biggest: %s. /ggc addons lists them all.",
- Size(m.total or 0), table.getn(m.rows), Biggest(m, 3))) +end
+local function PrintAddons(all)
- if not GentleGCMemory then
- Msg("per-addon memory needs the " .. COMPANION .. " folder from the Gentle GC zip in Interface/AddOns. "
-
.. "It has to load before your other addons, which is what the !!!! is for.") - return
- end
- local m = Measurement()
- if not m or table.getn(m.rows) == 0 then
- Msg("no per-addon measurement yet: /ggc addons measure, then /reload.")
- return
- end
- local rows = SortedRows(m)
- Msg(format("addon memory at login on %s: your addons kept %s; the game's own interface %s.",
- m.when or "?", Size(m.total or 0), Size(m.base or 0)))
- local shown = table.getn(rows)
- if not all and shown > ADDONS_TOP then shown = ADDONS_TOP end
- for i = 1, shown do
- DEFAULT_CHAT_FRAME:AddMessage(format(" %9s %s", Size(rows[i][2]), rows[i][1]))
- end
- if shown < table.getn(rows) then
- DEFAULT_CHAT_FRAME:AddMessage(format(" ...and %d more (/ggc addons all)", table.getn(rows) - shown))
- end
- for i = 1, table.getn(m.stages or {}) do
- DEFAULT_CHAT_FRAME:AddMessage(format(" then %s: %s", m.stages[i][1], Size(m.stages[i][2])))
- end
- if m.before and table.getn(m.before) > 0 then
- DEFAULT_CHAT_FRAME:AddMessage(" loaded before the measurement, so counted in the interface number: "
-
.. table.concat(m.before, ", ")) - end
- -- if the client held its load events back, one row gets everything
- if rows[1] and (m.total or 0) > 10240 and rows[1][2] > m.total * 0.8 and table.getn(rows) > 3 then
- Msg("one addon got almost all of it: this client may send its load events late, so the split may not be per addon.")
- end
- local grown = liveKB - (m.after or liveKB)
- Msg(format("live memory now ~%s, %s%s since that measurement.", Size(liveKB), grown >= 0 and "+" or "", Size(grown))) +end
+local function MeasureNext()
- if not GentleGCMemory or type(GentleGCMemoryDB) ~= "table" then
- PrintAddons(false) -- explains the missing companion
- return
- end
- GentleGCMemoryDB.measureNext = true
- Msg("type /reload to measure every addon. The loading screen takes a little longer, once.") +end
+--------------------------------------------------------------------------- +-- /ggc +--------------------------------------------------------------------------- + +local OPTIONS = {
- interval = "minInterval",
- maxinterval = "maxInterval",
- garbage = "minGarbageMB",
- garbagepct = "minGarbagePct",
- pressure = "pressureRoomPct",
- headroom = "combatHeadroomMB",
- report = "reportInterval",
- still = "stillSeconds",
- slow = "slowMs",
- allocwarn = "allocWarnMB", +}
+local function PrintStatus()
- Sync()
- local used, threshold = gcinfo()
- Msg(format("heap %.1fmb, auto-GC at %.1fmb, live ~%.1fmb, garbage ~%.1fmb%s",
- MB(used), MB(threshold), MB(liveKB), MB(GarbageKB()), inCombat and " (in combat)" or ""))
- if ratioMin then
- Msg(format("collector: threshold %.2fx live after the last full collection, lowest %.2fx; collecting from %.1f%% garbage, pressure at %.1fmb",
-
ratioLast, ratioMin, EffectiveGarbagePct(), MB(PressurePointKB(threshold)))) - else
- Msg(format("collector: threshold ratio not measured yet (no full collection this session); collecting from %.1f%% garbage, pressure at %.1fmb",
-
EffectiveGarbagePct(), MB(PressurePointKB(threshold)))) - end
- if pauseAvg then
- Msg(format("collections take ~%dms: %s", pauseAvg, careful
-
and "careful mode - only loading screens, death, taxi, NPC windows, AFK, after combat and pressure" -
or "normal mode")) - end
- Msg(format("session: %d collections, %d skipped, freed %.1fmb, avg %dms, worst %dms; %d automatic (%d in combat)",
- total.count, total.skipped, MB(total.freed),
- total.count > 0 and total.ms / total.count or 0, total.worst, total.auto, total.autoCombat))
- if total.ext > 0 then
- local parts = {}
- for name, n in pairs(extByName) do table.insert(parts, name .. " " .. n) end
- table.sort(parts)
- Msg(format("by other addons: %d (%d in combat) - %s", total.ext, total.extCombat, table.concat(parts, ", ")))
- end
- local up = GetTime() - loadTime
- if up >= 60 then
- Msg(format("garbage created: %.1f MB/s now (30 s average), %.1f MB/s this session",
-
MB(allocRate), MB(allocTotalKB / up))) - else
- Msg(format("garbage created: %.1f MB/s now (30 s average)", MB(allocRate)))
- end
- Msg(format("debug=%d interval=%ds maxinterval=%ds garbage=%dmb garbagepct=%d%% pressure=%d%% headroom=%dmb report=%ds still=%ds slow=%dms allocwarn=%dmb",
- DebugLevel(), db.minInterval, db.maxInterval, db.minGarbageMB, db.minGarbagePct,
- db.pressureRoomPct, db.combatHeadroomMB, db.reportInterval, db.stillSeconds, db.slowMs, db.allocWarnMB))
- local m = Measurement()
- if m and table.getn(m.rows) > 0 then
- Msg(format("biggest addons (measured %s): %s - /ggc addons", m.when or "?", Biggest(m, 3)))
- end
- Msg(db.hitchMs > 0 and ("freeze detector: on (" .. db.hitchMs .. "ms)") or "freeze detector: off (/ggc hitch)")
- Msg("ClassicAPI: stand-still " .. (hasSpeed and "on" or "not available") ..
- ", C_Timer " .. (hasTicker and "on" or "not available (using OnUpdate)")) +end
+SLASH_GENTLEGC1 = "/ggc" +SLASH_GENTLEGC2 = "/gentlegc" +SlashCmdList["GENTLEGC"] = function(msg)
- local _, _, cmd, val = string.find(string.lower(msg or ""), "^%s*(%S*)%s*(.-)%s*$")
- if cmd == "" or cmd == "status" then
- PrintStatus()
- elseif cmd == "now" then
- if inCombat then Msg("in combat - not collecting") else Collect("manual") end
- elseif cmd == "debug" then
- local n = tonumber(val)
- if n == nil then n = (db.debug > 0) and 0 or 1 end
- db.debug = n
- ggc_debug = nil
- Msg("debug " .. n .. (n == 0 and " (off)" or n == 1 and " (summaries)" or " (every collection)"))
- elseif cmd == "hitch" then
- local n = tonumber(val)
- if n == nil then n = (db.hitchMs > 0) and 0 or 250 end
- db.hitchMs = n
- HitchEnable()
- if n > 0 then
-
Msg("freeze detector on: reporting frames longer than " .. n .. "ms (/ggc hitch 0 to stop)") - else
-
Msg("freeze detector off") - end
- elseif cmd == "addons" then
- if val == "measure" then
-
MeasureNext() - else
-
PrintAddons(val == "all") - end
- elseif cmd == "hitches" then
- if table.getn(hitchLog) == 0 then
-
Msg(db.hitchMs > 0 and "no freezes recorded this session" or "freeze detector is off - /ggc hitch to turn it on") - else
-
for _, l in ipairs(hitchLog) do Msg(l) end - end
- elseif cmd == "reset" then
- for k, v in pairs(DEFAULTS) do db[k] = v end
- HitchEnable()
- Msg("settings reset to defaults")
- elseif OPTIONS[cmd] and tonumber(val) then
- db[OPTIONS[cmd]] = tonumber(val)
- Msg(cmd .. " = " .. val)
- else
- Msg("/ggc [status] | now | debug [0-2] | addons [all|measure] | hitch [ms] | hitches | reset")
- Msg("/ggc interval|maxinterval|report|still garbage|headroom|allocwarn garbagepct|pressure <%> slow ")
- end +end diff --git a/GentleGC.toc b/GentleGC.toc new file mode 100644 index 0000000..488b3fd --- /dev/null +++ b/GentleGC.toc @@ -0,0 +1,8 @@ +## Interface: 11200 +## Title: Gentle GC +## Notes: Schedules Lua garbage collection for moments you won't notice +## Author: Weird Vibes (MarcelineVQ); forks by TheRealFayz, jrc13245 +## Version: 2.5 +## SavedVariables: GentleGCDB
+GentleGC.lua diff --git a/tests/test_classicapi.lua b/tests/test_classicapi.lua new file mode 100644 index 0000000..93ac00a --- /dev/null +++ b/tests/test_classicapi.lua @@ -0,0 +1,71 @@ +-- WoW + ClassicAPI stubs +format = string.format +local now = 1000 +function GetTime() return now end +local combat, speed, falling = false, 7, false +function UnitAffectingCombat() return combat end +function UnitIsAFK() return false end +function GetUnitSpeed(u) return speed, 7, 0, 4.7 end +function IsFalling() return falling end +local log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } +SlashCmdList = {} +local tickerFn, tickerPeriod +C_Timer = { NewTicker = function(p, f) tickerPeriod, tickerFn = p, f; return {} end } +FRAMES = {} +function CreateFrame()
- local F = { scripts = {} }
- function F:RegisterEvent() end
- function F:UnregisterEvent() end
- function F:SetScript(k, f) self.scripts[k] = f end
- table.insert(FRAMES, F)
- return F +end +local function fire(e, a1) event, arg1 = e, a1; F.scripts.OnEvent() end +local function seconds(n) for i = 1, n do now = now + 1; tickerFn() end end
+LIVE = {} +for i = 1, 300000 do LIVE[i] = { i } end +local function makeGarbage(mb) local t = {}; for i = 1, mb * 6000 do t[i] = { i, i } end end +local function count(pat) local n = 0; for , m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end +local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end + +collectgarbage() +dofile(arg[1]) +F = FRAMES[1] +HF = FRAMES[2] +fire("ADDON_LOADED", "GentleGC") +SlashCmdList.GENTLEGC("debug 2") + +check("C_Timer ticker used (1s)", tickerFn ~= nil and tickerPeriod == 1) +check("no frame OnUpdate when C_Timer exists", F.scripts.OnUpdate == nil) + +makeGarbage(12) +speed = 7; seconds(10) +check("no stand-still collection while moving", count("standing still:") == 0) + +speed = 0; seconds(2) +check("not yet after 2s still", count("standing still:") == 0) +seconds(2) +check("collects after 3s standing still", count("standing still:") == 1) + +seconds(20) +check("does not repeat while still with no new garbage", count("standing still:") == 1) +SlashCmdList.GENTLEGC("status") +check("idle retries don't inflate skip count", count("collections, 0 skipped") == 1) + +makeGarbage(12); seconds(15) +check("respects minInterval, then collects again", count("standing still:") == 2) + +makeGarbage(12); falling = true; speed = 0; now = now + 60; seconds(5) +check("falling is not standing still", count("standing still:") == 2) +falling = false + +combat = true; fire("PLAYER_REGEN_DISABLED"); seconds(10) +check("never while in combat", count("standing still:") == 2) +combat = false; fire("PLAYER_REGEN_ENABLED") + +SlashCmdList.GENTLEGC("still 0"); makeGarbage(12); now = now + 60; seconds(10) +check("still 0 disables it", count("standing still:") == 2 or count("standing still:") == 3 and false) + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") diff --git a/tests/test_combat_auto.lua b/tests/test_combat_auto.lua new file mode 100644 index 0000000..3b7ffca --- /dev/null +++ b/tests/test_combat_auto.lua @@ -0,0 +1,15 @@ +format = string.format; local now = 1000; function GetTime() return now end +function UnitAffectingCombat() return true end function UnitIsAFK() return false end +DEFAULT_CHAT_FRAME = { AddMessage = function(, m) print(" " .. m) end }; SlashCmdList = {} +local F; function CreateFrame() local G = { e = {} }; F = F or G; local F = G; function F:RegisterEvent(x) end; function F:UnregisterEvent() end; function F:SetScript(k, f) self[k] = f end; return F end +collectgarbage(); dofile(arg[1]); event, arg1 = "ADDON_LOADED", "GentleGC"; F.OnEvent() +SlashCmdList.GENTLEGC("headroom 4"); SlashCmdList.GENTLEGC("debug 2") +event = "PLAYER_REGEN_DISABLED"; F.OnEvent() +local , t1 = gcinfo() +local keep = {} for i = 1, 60000 do keep[i] = {i} end -- live growth past raised threshold +keep = nil; local g = {} for i = 1, 60000 do g[i] = {i} end g = nil +arg1 = 1.1; F.OnUpdate() +local used, t2 = gcinfo() +print("re-raised headroom after in-combat auto GC:", t2 >= used + 4*1024 - 1 and "PASS" or "FAIL", t1, t2, used) +event = "PLAYER_REGEN_ENABLED"; F.OnEvent() +SlashCmdList.GENTLEGC("status") diff --git a/tests/test_factor.lua b/tests/test_factor.lua new file mode 100644 index 0000000..13d9bb3 --- /dev/null +++ b/tests/test_factor.lua @@ -0,0 +1,128 @@ +-- Collector-ratio tests: simulates the 1.12 client's collector, which sets the +-- threshold to 1.25x live (at least) instead of stock Lua 5.0's 2x. +-- Run: lua5.0 tests/test_factor.lua GentleGC.lua +format = string.format +local now = 1000 +function GetTime() return now end +function UnitAffectingCombat() return false end +function UnitIsAFK() return false end +local log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(, m) table.insert(log, m); print(" " .. m) end } +SlashCmdList = {} +FRAMES = {} +function CreateFrame()
- local f = { scripts = {} }
- function f:RegisterEvent() end
- function f:UnregisterEvent() end
- function f:SetScript(k, fn) self.scripts[k] = fn end
- table.insert(FRAMES, f)
- return f +end
+local FACTOR = 1.25 +local origCG = collectgarbage +local function setThreshold() local u = gcinfo(); origCG(math.floor(u * FACTOR)) end +-- the modded collector: a full collection leaves the threshold at FACTOR x live +collectgarbage = function(n)
- if n == nil or n == 0 then origCG(); setThreshold() else origCG(n) end +end +-- the game's own automatic collection under the mod +local function autoGC() origCG(); setThreshold() end
+LIVE = {} +for i = 1, 300000 do LIVE[i] = { i } end +autoGC() + +dofile(arg[1]) +local F = FRAMES[1] +local function fire(e, a1) event, arg1 = e, a1; this = F; F.scripts.OnEvent() end +fire("ADDON_LOADED", "GentleGC") +local cmd = SlashCmdList.GENTLEGC +cmd("debug 2") +SF = FRAMES[4] + +local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end +local function lastMatch(pat) for i = table.getn(log), 1, -1 do if string.find(log[i], pat) then return log[i] end end; return "" end +local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end +local function tick(sec) local n = sec * 10; for i = 1, n do now = now + 0.1; arg1 = 0.1; this = F; F.scripts.OnUpdate() end end +local function liveMB() local _, _, v = string.find(lastMatch("heap .* live ~"), "live ~([%d%.]+)mb"); return tonumber(v) end +local function realLiveMB() local u = gcinfo(); return u / 1024 end +-- heap creeps toward the threshold over frames (the sampler sees each one), +-- then the game's collector fires at the threshold +local SF = FRAMES[4] +local keepAlive = {} +local function growToThresholdThenAutoGC()
- local _, t = gcinfo()
- local junk = {}
- local i = 0
- while gcinfo() < t * 0.98 do
- for k = 1, 300 do i = i + 1; junk[i] = { i } end
- if SF then SF.scripts.OnUpdate() end
- end
- junk = nil
- autoGC() +end +-- garbage that stays below the 1.25x threshold so Lua never collects on its own +local function garbage(kb)
- local target = gcinfo() + kb
- local t = {}
- local i = 0
- while gcinfo() < target do i = i + 1; t[i] = { i, i } end +end
+print("1. automatic GC before GentleGC has measured the ratio") +now = now + 60 +growToThresholdThenAutoGC() +tick(1.1) +check("automatic GC detected", count("automatic GC detected") == 1) +cmd("status") +local , thr = gcinfo() +check("live estimate is not threshold/2", liveMB() > (thr / 2) / 1024 + 1) +check("status says the ratio isn't measured yet", count("not measured yet") == 1) + +print("2. no useless collection right after it (the bug)") +now = now + 60 +fire("MERCHANT_SHOW") +check("skipped: almost no garbage", count("merchant:") == 0) + +print("3. own collection measures the ratio") +cmd("now") +cmd("status") +check("ratio measured as 1.25x", string.find(lastMatch("collector:"), "threshold 1%.25x live after the last full collection, lowest 1%.25x") ~= nil) +check("garbage minimum capped at half the 25% room", string.find(lastMatch("collector:"), "collecting from 12%.5%% garbage") ~= nil) + +print("4. automatic GC after measuring: live estimate is right") +growToThresholdThenAutoGC() +tick(1.1) +cmd("status") +local real = realLiveMB() +check("live estimate within 1mb of real live memory", math.abs(liveMB() - real) < 1) +now = now + 60 +fire("BANKFRAME_OPENED") +check("still no useless collection", count("bank:") == 0) + +print("5. enough garbage: normal collections still happen") +local live = gcinfo() +garbage(math.floor(live * 0.14)) -- 14% of live: above the 12.5% minimum +now = now + 60 +fire("MAIL_SHOW") +check("mailbox collection ran", count("mailbox:") == 1) +check("it freed real garbage", string.find(lastMatch("mailbox:"), "freed [1-9]") ~= nil) + +print("6. pressure fires before the 1.25x threshold") +now = now + 60 +local l2, t2 = gcinfo() +-- pressure point = live + 70% of the room = 1.175x live; stop short of 1.25x +garbage(math.floor(l2 * 0.19)) +local used = gcinfo() +check("heap is between pressure point and threshold", used > l2 * 1.175 and used < t2) +tick(1.1) +check("pressure collection ran", count("pressure:") == 1) +check("pressure collection freed garbage", string.find(lastMatch("pressure:"), "freed [1-9]") ~= nil) +check("no automatic GC needed", count("automatic GC detected") == 2) + +print("7. pressure can't loop") +tick(10) +check("one pressure collection only", count("pressure:") == 1) + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") diff --git a/tests/test_hitch.lua b/tests/test_hitch.lua new file mode 100644 index 0000000..ab69aee --- /dev/null +++ b/tests/test_hitch.lua @@ -0,0 +1,232 @@ +-- Freeze detector tests. Run: lua5.0 tests/test_hitch.lua GentleGC.lua +format = string.format +local now = 1000 +function GetTime() return now end +local combat = false +function UnitAffectingCombat() return combat end +function UnitIsAFK() return false end +local log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(, m) table.insert(log, m); print(" " .. m) end } +SlashCmdList = {} + +-- a dragged window: an anonymous child of a named frame +local auxFrame = { GetName = function() return "AuxFrame" end } +local auxButton = { GetName = function() return nil end, GetParent = function() return auxFrame end } +local focus = nil +function GetMouseFocus() return focus end + +FRAMES = {} +function CreateFrame()
- local f = { scripts = {} }
- function f:RegisterEvent() end
- function f:UnregisterEvent() end
- function f:SetScript(k, fn) self.scripts[k] = fn end
- table.insert(FRAMES, f)
- return f +end
+LIVE = {}
+for i = 1, 300000 do LIVE[i] = { i } end
+collectgarbage()
+dofile(arg[1])
+local F, HF = FRAMES[1], FRAMES[2]
+local function fire(e, a1) event, arg1 = e, a1; F.scripts.OnEvent() end
+fire("ADDON_LOADED", "GentleGC")
+local cmd = SlashCmdList.GENTLEGC
+
+-- one rendered frame taking sec seconds; during runs inside it
+local function frame(sec, during)
- if during then during() end
- now = now + sec
- arg1 = sec
- if F.scripts.OnUpdate then F.scripts.OnUpdate() end
- if HF.scripts.OnUpdate then HF.scripts.OnUpdate() end +end +local function frames(n) for i = 1, n do frame(0.016) end end +local function last() return log[table.getn(log)] or "" end +local function freezes() local n = 0; for _, m in ipairs(log) do if string.find(m, "freeze %d") then n = n + 1 end end; return n end +local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end
+check("off by default", HF.scripts.OnUpdate == nil) +cmd("hitch") +check("/ggc hitch turns it on at 250ms", HF.scripts.OnUpdate ~= nil and GentleGCDB.hitchMs == 250) + +frames(5); frame(0.2); frames(3) +check("normal and 200ms frames are not reported", freezes() == 0) + +print("-- freeze with no GC while dragging AuxFrame") +focus = auxButton +frame(2.0) +check("2s freeze reported", freezes() == 1) +check("blamed on something other than GC", string.find(last(), "no garbage collection") ~= nil) +check("names the window under the mouse", string.find(last(), "mouse over AuxFrame") ~= nil) +focus = nil +frames(3) + +print("-- freeze containing the game's automatic GC") +-- the heap creeps up to just below the threshold over normal frames... +local SF = FRAMES[4] +do
- local junk = {}
- local _, t = gcinfo()
- while gcinfo() < t * 0.97 do
- for i = 1, 500 do table.insert(junk, { i }) end
- SF.scripts.OnUpdate()
- end
- junk = nil +end +-- ...and the collector fires inside a long frame +frame(0.6, function()
- local _, t = gcinfo()
- local junk = {}
- while true do -- allocate until Lua collects on its own
- local used, thr = gcinfo()
- if thr ~= t then break end
- table.insert(junk, { 1, 2, 3 })
- if table.getn(junk) > 5000 then junk = {} end
- end +end) +check("automatic GC identified", string.find(last(), "the game's automatic garbage collection") ~= nil) +frames(3)
+print("-- freeze containing a GentleGC collection") +local g = {}; for i = 1, 60000 do g[i] = { i } end; g = nil +frame(0.4, function() cmd("now") end) +check("GentleGC collection identified", string.find(last(), "GentleGC collection %(manual%)") ~= nil) +frames(3) + +print("-- freeze that allocates a lot without collecting") +local keep = {} +frame(0.5, function() for i = 1, 40000 do keep[i] = { i, i } end end) +check("allocation reported", string.find(last(), "allocated during it") ~= nil) +check("still not blamed on GC", string.find(last(), "no garbage collection") ~= nil) +keep = nil +frames(3) + +print("-- entering combat (threshold raise) inside a freeze is not a GC") +combat = true +frame(0.5, function() fire("PLAYER_REGEN_DISABLED") end) +check("threshold raise not mistaken for GC", string.find(last(), "no garbage collection") ~= nil) +check("marked in combat", string.find(last(), "%(in combat%)") ~= nil) +combat = false; fire("PLAYER_REGEN_ENABLED"); frames(3) + +print("-- loading screens are ignored") +local before = freezes() +fire("PLAYER_LEAVING_WORLD"); frame(15); frame(5) +fire("PLAYER_ENTERING_WORLD"); frame(1.5); frame(1.0); frame(0.8) +check("no reports for loading screen or the frames right after", freezes() == before) +frame(1.0) +check("reports again afterwards", freezes() == before + 1) + +print("-- history and off") +local n0 = table.getn(log) +cmd("hitches") +check("/ggc hitches lists recorded freezes", table.getn(log) - n0 >= 5) +cmd("hitch 0") +check("/ggc hitch 0 turns it off", HF.scripts.OnUpdate == nil) +local b = freezes(); frame(3); check("no reports when off", freezes() == b) +cmd("hitch 500"); frame(0.4); check("custom limit respected (400ms < 500ms)", freezes() == b) + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") + +--------------------------------------------------------------------------- +print("== event recording (client with RegisterAllEvents)") +-- reload the addon on frames that support RegisterAllEvents +FRAMES = {} +log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } +local regAll = 0 +function CreateFrame()
- local f = { scripts = {}, all = false }
- function f:RegisterEvent() end
- function f:UnregisterEvent() end
- function f:RegisterAllEvents() self.all = true; regAll = regAll + 1 end
- function f:UnregisterAllEvents() self.all = false end
- function f:SetScript(k, fn) self.scripts[k] = fn end
- table.insert(FRAMES, f)
- return f +end +GentleGCDB = nil +SlashCmdList = {} +dofile(arg[1]) +F, HF = FRAMES[1], FRAMES[2] +local EF = FRAMES[3] +event, arg1 = "ADDON_LOADED", "GentleGC"; F.scripts.OnEvent() +cmd = SlashCmdList.GENTLEGC +local function last2() return log[table.getn(log)] or "" end +local function ev(name, a1, dt)
- if dt then now = now + dt end
- if EF.all and EF.scripts.OnEvent then event, arg1 = name, a1; EF.scripts.OnEvent() end +end +local function endFrame(dt)
- now = now + (dt or 0)
- arg1 = 0.016
- if HF.scripts.OnUpdate then HF.scripts.OnUpdate() end +end +local function quiet(n) for i = 1, n do now = now + 0.016; endFrame() end end
+check("event frame not listening while detector off", EF.all == false) +cmd("hitch") +check("RegisterAllEvents on /ggc hitch", EF.all == true and regAll == 1) +quiet(3) + +-- 0.5s spent right after an addon message +ev("UNIT_AURA", "raid3", 0.005) +ev("UNIT_AURA", "raid7", 0.005) +ev("CHAT_MSG_ADDON", "BigWigs", 0.005) +ev("UNIT_HEALTH", "raid1", 0.50) +endFrame(0.01) +local l = last2() +check("lists events with counts", string.find(l, "4 events: UNIT_AURA x2") ~= nil) +check("finds the longest gap after CHAT_MSG_ADDON (BigWigs)", string.find(l, "longest gap 0%.50s after CHAT_MSG_ADDON %(BigWigs%)") ~= nil) +quiet(2) + +-- freeze with no events at all +endFrame(0.6) +check("no events reported", string.find(last2(), "no game events fired during it") ~= nil) +quiet(2) + +-- time lost before the first event +ev("BAG_UPDATE", 0, 0.7) +endFrame(0.01) +check("gap before the first event", string.find(last2(), "longest gap 0%.70s before the first event") ~= nil) +quiet(2) + +-- time lost after the last event +ev("GUILD_ROSTER_UPDATE", nil, 0.01) +endFrame(0.45) +check("gap after the last event", string.find(last2(), "after GUILD_ROSTER_UPDATE, the last event") ~= nil) +quiet(2) + +-- memory jump attributed to an event +local keep +ev("PLAYER_TARGET_CHANGED", nil, 0.01) +ev("AUCTION_ITEM_LIST_UPDATE", nil, 0.01) +keep = {}; for i = 1, 30000 do keep[i] = { i, i } end +ev("UNIT_AURA", "target", 0.4) +endFrame(0.01) +check("allocation attributed to AUCTION_ITEM_LIST_UPDATE", string.find(last2(), "allocated after AUCTION_ITEM_LIST_UPDATE") ~= nil) +keep = nil +quiet(2) + +-- more events than the ring holds +for i = 1, 100 do ev("COMBAT_TEXT_UPDATE", "x", 0.003) end +endFrame(0.01) +check("ring overflow reported", string.find(last2(), "100 events: COMBAT_TEXT_UPDATE x64 %(last 64 kept%)") ~= nil) +quiet(2) + +-- long argument is shortened +ev("CHAT_MSG_ADDON", "AVeryLongAddonPrefixThatGoesOnAndOn", 0.01) +endFrame(0.5) +check("long argument shortened", string.find(last2(), "AVeryLongAddonPrefixThat%.%.%.") ~= nil) +quiet(2) + +-- events from an earlier frame aren't blamed +ev("SPELLS_CHANGED", nil, 0.01); endFrame(0.01) +endFrame(0.5) +check("previous frame's events not included", string.find(last2(), "no game events fired during it") ~= nil) + +cmd("hitch 0") +check("UnregisterAllEvents on /ggc hitch 0", EF.all == false) + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") diff --git a/tests/test_memory.lua b/tests/test_memory.lua new file mode 100644 index 0000000..79d16a6 --- /dev/null +++ b/tests/test_memory.lua @@ -0,0 +1,154 @@ +-- Per-addon memory: the !!!!GentleGC_Memory companion measuring a simulated +-- login, and Gentle GC reporting it. +-- Run: lua5.0 tests/test_memory.lua GentleGC.lua "!!!!GentleGC_Memory/GentleGC_Memory.lua" +local MAIN, COMPANION = arg[1], arg[2] +format = string.format +local now = 1000 +function GetTime() return now end +function UnitAffectingCombat() return false end +function UnitIsAFK() return false end +function date() return "2026-10-21 12:00" end +local log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } + +local origCG = collectgarbage +local ADDONS, LOADED + +-- frames, with events dispatched in registration order like the client +local FRAMES +function CreateFrame()
- local f = { scripts = {}, events = {} }
- function f:RegisterEvent(e) self.events[e] = true end
- function f:UnregisterEvent(e) self.events[e] = nil end
- function f:UnregisterAllEvents() self.events = {} end
- function f:RegisterAllEvents() end
- function f:SetScript(k, fn) self.scripts[k] = fn end
- table.insert(FRAMES, f)
- return f +end +local function fire(e, a1)
- for _, f in ipairs(FRAMES) do
- if f.events[e] and f.scripts.OnEvent then event, arg1 = e, a1; this = f; f.scripts.OnEvent() end
- end +end
+function GetNumAddOns() return table.getn(ADDONS) end +function GetAddOnInfo(i)
- local a = type(i) == "number" and ADDONS[i] or nil
- if not a then return nil end
- return a.name, a.name, "", a.enabled and 1 or nil +end +function IsAddOnLoaded(name) return LOADED[name] and 1 or nil end
+local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end
+local function lastMatch(pat) for i = table.getn(log), 1, -1 do if string.find(log[i], pat) then return log[i] end end; return "" end
+local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end
+
+-- an addon's data: tables kept in a global, roughly mb megabytes
+KEEP = {}
+local function allocate(name, mb)
- local t = {}
- for i = 1, mb * 9000 do t[i] = { i } end
- KEEP[name] = t +end
+-- one full login: every enabled addon in order, then login and world events +-- sizes: MB per addon; withCompanion: install the companion +local function login(sizes, withCompanion)
- FRAMES, LOADED, KEEP = {}, {}, {}
- collectgarbage = origCG
- SlashCmdList = {}
- GentleGCMemory = nil
- origCG()
- for _, a in ipairs(ADDONS) do
- if a.enabled and (withCompanion or a.name ~= "!!!!GentleGC_Memory") then
-
if a.name == "!!!!GentleGC_Memory" then -
dofile(COMPANION) -
elseif a.name == "GentleGC" then -
dofile(MAIN) -
else -
allocate(a.name, sizes[a.name] or 0) -
end -
LOADED[a.name] = true -
fire("ADDON_LOADED", a.name) - end
- end
- allocate("variables", 0.5)
- fire("PLAYER_LOGIN")
- allocate("login setup", 3)
- fire("PLAYER_ENTERING_WORLD") +end
+local SIZES = { ["!!!ClassicAPI"] = 1, Atlas = 2, Bagshui = 5, pfQuest = 20, Wrekkit = 1 } +ADDONS = {
- { name = "!!!ClassicAPI", enabled = true },
- { name = "!!!!GentleGC_Memory", enabled = true },
- { name = "Atlas", enabled = true },
- { name = "Bagshui", enabled = true },
- { name = "GentleGC", enabled = true },
- { name = "pfQuest", enabled = true },
- { name = "Wrekkit", enabled = true }, +} +-- (the client loads "!!!!" before "!!!", but the test puts ClassicAPI first to +-- check the "loaded before" list)
+local function rowMB(name)
- local m = GentleGCMemoryDB.measured
- for i = 1, table.getn(m.rows) do if m.rows[i][1] == name then return m.rows[i][2] / 1024 end end +end +local function near(v, want) return v and math.abs(v - want) <= math.max(0.35, want * 0.15) end
+print("1. first login with the companion: measured") +login(SIZES, true) +local m = GentleGCMemoryDB and GentleGCMemoryDB.measured +check("a measurement was saved", m ~= nil and table.getn(m.rows) >= 5) +check("first login is the reason", m and m.reason == "first login") +check("Atlas ~2 MB", near(rowMB("Atlas"), 2)) +check("Bagshui ~5 MB", near(rowMB("Bagshui"), 5)) +check("pfQuest ~20 MB", near(rowMB("pfQuest"), 20)) +check("Wrekkit ~1 MB", near(rowMB("Wrekkit"), 1)) +check("ClassicAPI listed as loaded before", m and m.before[1] == "!!!ClassicAPI") +check("summary printed after loading", string.find(lastMatch("measured your addons"), "Biggest: pfQuest") ~= nil) +check("companion stopped measuring", GentleGCMemory.measuring == false) + +print("2. /ggc addons") +local cmd = SlashCmdList.GENTLEGC +cmd("addons") +check("header line", count("addon memory at login on 2026%-10%-21") == 1) +local first +for i = 1, table.getn(log) do if string.find(log[i], "addon memory at login") then first = log[i + 1] end end +check("biggest addon on the first row", first and string.find(first, "pfQuest") ~= nil) +check("login stage shown", count("then addons setting up at login") == 1) +check("loaded-before line", count("counted in the interface number: !!!ClassicAPI") == 1) +cmd("") +check("status names the biggest addons", string.find(lastMatch("biggest addons"), "pfQuest") ~= nil) +check("measuring collections not counted as other addons'", count("by other addons") == 0) +check("nor as automatic", string.find(lastMatch("session:"), "0 automatic") ~= nil) + +print("3. next login, same addons: not measured again") +local when = m +login(SIZES, true) +check("same measurement kept", GentleGCMemoryDB.measured == when) +check("no summary this time", count("measured your addons") == 1) + +print("4. an addon added: measured again") +table.insert(ADDONS, { name = "ShaguTweaks", enabled = true }) +SIZES.ShaguTweaks = 3 +login(SIZES, true) +check("new measurement", GentleGCMemoryDB.measured ~= when and GentleGCMemoryDB.measured.reason == "addons changed") +check("ShaguTweaks ~3 MB", near(rowMB("ShaguTweaks"), 3)) + +print("5. /ggc addons measure: measured on the next reload") +local before = GentleGCMemoryDB.measured +SlashCmdList.GENTLEGC("addons measure") +check("asks for a reload", count("type /reload to measure") == 1) +login(SIZES, true) +check("measured because it was requested", GentleGCMemoryDB.measured ~= before and GentleGCMemoryDB.measured.reason == "requested") + +print("6. without the companion") +GentleGCMemoryDB = nil +login(SIZES, false) +SlashCmdList.GENTLEGC("addons") +check("explains the companion folder", count("needs the !!!!GentleGC_Memory folder") == 1) + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") diff --git a/tests/test_raid.lua b/tests/test_raid.lua new file mode 100644 index 0000000..70f2144 --- /dev/null +++ b/tests/test_raid.lua @@ -0,0 +1,242 @@ +-- Raid-scale behaviour: careful mode when collections are slow, collections +-- run by other addons, and the garbage creation rate. +-- Run: lua5.0 tests/test_raid.lua GentleGC.lua +format = string.format +local now = 1000 +function GetTime() return now end +local combat, speed = false, 7 +function UnitAffectingCombat() return combat end +function UnitIsAFK() return false end +function GetUnitSpeed() return speed, 7, 0, 4.7 end -- ClassicAPI: enables stand-still +local log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } +SlashCmdList = {} +FRAMES = {} +function CreateFrame()
- local f = { scripts = {} }
- function f:RegisterEvent() end
- function f:UnregisterEvent() end
- function f:SetScript(k, fn) self.scripts[k] = fn end
- table.insert(FRAMES, f)
- return f +end
+-- the call stack another addon's collectgarbage() call is made from +local caller = nil +function debugstack()
- local s = "Interface\AddOns\GentleGC\GentleGC.lua:400: in function `WrappedCollect'\n"
- if caller then s = s .. "Interface\AddOns\" .. caller .. "\core.lua:12: in function `FreeMemory'\n" end
- return s .. "[C]: ?\n" +end
+-- a slow collector: every full collection advances the clock by gcCost
+local gcCost = 0.3
+local origCG = collectgarbage
+collectgarbage = function(n)
- if n == nil or n == 0 then now = now + gcCost end
- return origCG(n) +end +local unwrappedCG = collectgarbage -- what an addon that localized it before us holds
+LIVE = {} +for i = 1, 300000 do LIVE[i] = { i } end +origCG() + +dofile(arg[1]) +local F = FRAMES[1] +local function fire(e, a1) event, arg1 = e, a1; this = F; F.scripts.OnEvent() end +fire("ADDON_LOADED", "GentleGC") +local SF = FRAMES[4] +local cmd = SlashCmdList.GENTLEGC +cmd("debug 2") + +local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end +local function lastMatch(pat) for i = table.getn(log), 1, -1 do if string.find(log[i], pat) then return log[i] end end; return "" end +local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end +-- one rendered frame: per-frame sampler, then the main frame's OnUpdate +local HF = FRAMES[2] +local function frame(dt)
- now = now + dt
- SF.scripts.OnUpdate()
- arg1 = dt; this = F
- F.scripts.OnUpdate()
- if HF.scripts.OnUpdate then HF.scripts.OnUpdate() end +end +local function seconds(n) for i = 1, n * 10 do frame(0.1) end end +local function garbageMB(mb)
- local t = {}
- for i = 1, mb * 7000 do t[i] = { i, i } end +end +local function liveMB() local _, _, v = string.find(lastMatch("heap .* live ~"), "live ~([%d%.]+)mb"); return tonumber(v) end
+--------------------------------------------------------------------------- +print("1. slow collections switch to careful mode") +garbageMB(8) +fire("PLAYER_LEAVING_WORLD") +fire("PLAYER_ENTERING_WORLD") +check("loading-screen collection ran", count("loading screen:") >= 1) +cmd("status") +check("status shows careful mode", string.find(lastMatch("collections take"), "~[23]%d%dms: careful mode") ~= nil) + +print("2. careful mode: no stand-still or idle collections") +garbageMB(8) +speed = 0 +seconds(10) +check("no stand-still collection", count("standing still:") == 0) +now = now + 400 +seconds(3) +check("no idle collection", count("idle:") == 0) +speed = 7 + +print("3. careful mode: NPC windows and pressure still collect") +fire("MERCHANT_SHOW") +check("merchant collection ran", count("merchant:") == 1) +local _, thr = gcinfo() +do
- local junk = {}
- local i = 0
- while gcinfo() < thr * 0.9 do
- for k = 1, 500 do i = i + 1; junk[i] = { i } end
- if i > 30000 then junk = {}; i = 0 end
- SF.scripts.OnUpdate()
- end +end +seconds(6) +check("pressure collection ran", count("pressure:") == 1)
+print("4. fast collections switch back to normal") +gcCost = 0.01 +for k = 1, 6 do now = now + 40; garbageMB(8); fire("MAIL_SHOW") end +cmd("status") +check("back to normal mode", string.find(lastMatch("collections take"), "normal mode") ~= nil) +garbageMB(8) +now = now + 40 +speed = 0 +seconds(5) +check("stand-still collection runs again", count("standing still:") == 1) +speed = 7 + +print("5. /ggc slow 0 turns careful mode off") +gcCost = 0.3 +cmd("slow 0") +for k = 1, 3 do now = now + 40; garbageMB(8); fire("BANKFRAME_OPENED") end +cmd("status") +check("normal mode despite slow collections", string.find(lastMatch("collections take"), "normal mode") ~= nil) +cmd("slow 100") +gcCost = 0.01 + +--------------------------------------------------------------------------- +print("6. another addon's collectgarbage() is attributed, not 'automatic'") +local autoBefore = count("automatic GC detected") +garbageMB(10) +seconds(1) +caller = "Wrekkit" +collectgarbage() +caller = nil +seconds(2) +check("logged as Wrekkit's collection", string.find(lastMatch("collection by"), "collection by Wrekkit: freed") ~= nil) +check("not counted as automatic", count("automatic GC detected") == autoBefore) +cmd("status") +check("status lists it by name", string.find(lastMatch("by other addons"), "Wrekkit 1") ~= nil) +local real = gcinfo() / 1024 +check("live estimate matches real live memory", math.abs(liveMB() - real) < 1) + +print("7. an addon setting the threshold (no collection) changes nothing") +local liveBefore = liveMB() +caller = "FpsWatch" +local u = gcinfo() +collectgarbage(u * 3) +caller = nil +seconds(2) +cmd("status") +check("no collection counted", string.find(lastMatch("by other addons"), "FpsWatch") == nil) +check("no automatic GC counted", count("automatic GC detected") == autoBefore) +check("live estimate unchanged", math.abs(liveMB() - liveBefore) < 0.5) +origCG(math.floor(u * 1.5) + 2048) -- (back to a normal threshold for the next steps) +seconds(1) + +print("8. an addon we couldn't wrap: detected from the heap dropping below the threshold") +garbageMB(10) +seconds(1) +unwrappedCG() +seconds(2) +check("counted as another addon's (unknown)", string.find(lastMatch("collection by"), "collection by unknown addon") ~= nil) +check("still not counted as automatic", count("automatic GC detected") == autoBefore) + +print("9. the game's collector firing at the threshold is still 'automatic'") +do
- local _, t = gcinfo()
- local junk = {}
- local i = 0
- local _, cur = gcinfo()
- while cur == t do
- for k = 1, 500 do i = i + 1; junk[i] = { i } end
- if i > 30000 then junk = {}; i = 0 end
- SF.scripts.OnUpdate()
- _, cur = gcinfo()
- end +end +seconds(1) +check("counted as automatic", count("automatic GC detected") == autoBefore + 1)
+print("10. another addon collecting in combat: combat headroom is restored") +combat = true; fire("PLAYER_REGEN_DISABLED") +garbageMB(6) +caller = "Wrekkit" +collectgarbage() +caller = nil +local used, t = gcinfo() +check("threshold raised again after it", t >= used + 32 * 1024 - 64) +combat = false; fire("PLAYER_REGEN_ENABLED") +seconds(1) + +print("11. freeze detector names the addon") +cmd("hitch") +seconds(1) +garbageMB(10) +caller = "Wrekkit" +now = now + 0.4 +collectgarbage() +caller = nil +frame(0.05) +check("freeze blamed on Wrekkit", string.find(lastMatch("freeze"), "run by Wrekkit") ~= nil) +cmd("hitch 0") + +--------------------------------------------------------------------------- +print("12. garbage creation rate") +cmd("allocwarn 1") +now = now + 120 +for s = 1, 40 do
- for f = 1, 10 do
- garbageMB(0.2) -- ~2 MB/s
- frame(0.1)
- end +end +cmd("status") +local _, _, rate = string.find(lastMatch("garbage created"), "([%d%.]+) MB/s now") +rate = tonumber(rate) +check("rate is measured (about 2 MB/s)", rate and rate > 1.2 and rate < 3.5) +check("warning shown above the limit", count("garbage is being created at") == 1) +for s = 1, 20 do for f = 1, 10 do garbageMB(0.2); frame(0.1) end end +check("warning not repeated within 10 minutes", count("garbage is being created at") == 1)
+--------------------------------------------------------------------------- +print("13. a wrapper that blocks collectgarbage is reported, not counted") +FRAMES = {} +log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } +GentleGCDB = nil +SlashCmdList = {} +collectgarbage = function(n) end -- an addon loaded before us that swallows every call +dofile(arg[1]) +F = FRAMES[1] +fire("ADDON_LOADED", "GentleGC") +SlashCmdList.GENTLEGC("debug 1") +garbageMB(8) +SlashCmdList.GENTLEGC("now") +check("reported as blocked", count("did not run") == 1) +SlashCmdList.GENTLEGC("status") +check("not counted as a collection", string.find(lastMatch("session:"), "0 collections") ~= nil) +check("ratio not learned from it", count("not measured yet") == 1) + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") diff --git a/tests/test_stock.lua b/tests/test_stock.lua new file mode 100644 index 0000000..58dfe2b --- /dev/null +++ b/tests/test_stock.lua @@ -0,0 +1,133 @@ +-- WoW API stubs ------------------------------------------------------------ +format = string.format +local now = 1000 +function GetTime() return now end +local combat, afk = false, false +function UnitAffectingCombat() return combat end +function UnitIsAFK() return afk end +local log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(self, m) table.insert(log, m); print(" chat: " .. m) end } +SlashCmdList = {} +FRAMES = {} +function CreateFrame()
- local F = { scripts = {}, events = {} }
- function F:RegisterEvent(e) self.events[e] = true end
- function F:UnregisterEvent(e) self.events[e] = nil end
- function F:SetScript(k, f) self.scripts[k] = f end
- table.insert(FRAMES, F)
- return F +end +local function fire(e, a1)
- assert(F.events[e], "not registered: " .. e)
- event, arg1 = e, a1; this = F
- F.scripts.OnEvent() +end +local function update(sec) -- advance time in 0.1s frames
- local n = sec * 10
- for i = 1, n do now = now + 0.1; arg1 = 0.1; this = F; F.scripts.OnUpdate() end +end
+-- live data (~8 MB) and a garbage generator ------------------------------- +LIVE = {} +for i = 1, 60000 do LIVE[i] = { i, "x" .. i } end +local function makeGarbage(mb)
- local t = {}
- for i = 1, mb * 6000 do t[i] = { i, i } end -- roughly 1 MB per 6000 small tables +end
+collectgarbage() +dofile(arg[1]) +F = FRAMES[1] +HF = FRAMES[2] +fire("ADDON_LOADED", "GentleGC") +local cmd = SlashCmdList.GENTLEGC +cmd("debug 2") + +local function count() local n = 0; for _, m in ipairs(log) do if string.find(m, "%->") then n = n + 1 end end; return n end +local function check(name, cond) print((cond and "PASS " or "FAIL ") .. name); if not cond then FAILED = true end end + +print("1. login loading screen collects") +makeGarbage(6); fire("PLAYER_ENTERING_WORLD"); check("collected at login", count() == 1) + +print("2. small garbage is skipped") +makeGarbage(1); now = now + 60; fire("MERCHANT_SHOW"); check("skipped (little garbage)", count() == 1) + +print("3. interval respected") +makeGarbage(6); fire("BANKFRAME_OPENED"); fire("MAIL_SHOW") +check("one collection for two quick events", count() == 2) +makeGarbage(6); fire("MAIL_SHOW"); check("within minInterval -> no collect", count() == 2) + +print("4. combat: threshold raised, no automatic GC, deferred collection after") +now = now + 100 +local used0, thr0 = gcinfo() +fire("PLAYER_REGEN_DISABLED"); combat = true +local _, thr1 = gcinfo() +check("threshold raised by headroom", thr1 >= used0 + 32 * 1024 - 1) +-- allocate past the ORIGINAL threshold but keep it alive so we can see it +local keep = {} +local target = thr0 + 1024 +while gcinfo() < target do table.insert(keep, { 1, 2, 3 }) end +local usedMid = gcinfo() +check("heap went past old threshold without auto-GC", usedMid > thr0) +update(20); check("no collection in combat (fallback timer)", count() == 2) +fire("ZONE_CHANGED_NEW_AREA"); check("no collection on safe event in combat", count() == 2) +keep = nil +fire("PLAYER_REGEN_ENABLED"); combat = false +check("collected after combat", count() == 3) +local u2, t2 = gcinfo(); check("threshold back to normal (2x live)", t2 <= u2 * 2 + 64) + +print("5. short fight with nothing to collect restores threshold") +now = now + 100 +local _, tb = gcinfo(); fire("PLAYER_REGEN_DISABLED"); fire("PLAYER_REGEN_ENABLED") +local _, ta = gcinfo(); check("threshold restored", ta == tb); check("no collection", count() == 3) + +print("6. pressure: collect pre-emptively near threshold, out of combat") +now = now + 40 +local keep2 = {} +local _, thr = gcinfo() +while gcinfo() < thr * 0.86 do table.insert(keep2, { 1, 2 }) end +keep2 = nil +update(1.5); check("pressure collection", count() == 4) + +print("7. fallback idle collection after maxInterval") +makeGarbage(8); update(310); check("idle collection", count() == 5) + +print("8. AFK") +makeGarbage(8); now = now + 60; afk = true; fire("PLAYER_FLAGS_CHANGED"); afk = false +check("afk collection", count() == 6) + +print("8b. automatic GC is detected and resynced") +cmd("debug 2") +local nAuto = 0 +for _, m in ipairs(log) do if string.find(m, "automatic GC detected") then nAuto = nAuto + 1 end end +-- the heap grows frame by frame until Lua collects on its own +local SF = FRAMES[4] +local _, t0 = gcinfo() +local junk = {} +local t = t0 +while t == t0 do -- (a local declared inside repeat isn't visible to until in Lua 5.0)
- for i = 1, 2000 do table.insert(junk, { i }) end
- if table.getn(junk) > 20000 then junk = {} end
- SF.scripts.OnUpdate()
- _, t = gcinfo() +end +junk = nil +update(1.1) +local nAuto2 = 0 +for _, m in ipairs(log) do if string.find(m, "automatic GC detected") then nAuto2 = nAuto2 + 1 end end +check("automatic GC detected", nAuto2 > nAuto) +local u, t = gcinfo() +check("pressure not falsely triggered after auto GC", u < t * 0.85)
+print("9. slash commands") +cmd("interval 45"); check("interval set", GentleGCDB.minInterval == 45) +cmd("now"); check("manual collect", count() >= 7) +cmd(""); cmd("bogus"); cmd("reset"); check("reset", GentleGCDB.minInterval == 30) + +print("10. summary report") +cmd("debug 1"); update(301) +local found = false +for _, m in ipairs(log) do if string.find(m, "last %d+ min") then found = true end end +check("summary printed", found) + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED")