From 946f50fc29cae8a193eefa73eaa1ed42e86842ef Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Fri, 30 Oct 2026 16:47:32 +0000 Subject: [PATCH] Apply Patch --- !!!!GentleGC_Memory/!!!!GentleGC_Memory.toc | 8 + !!!!GentleGC_Memory/GentleGC_Memory.lua | 118 +++ GentleGC.lua | 1015 +++++++++++++++++++ GentleGC.toc | 8 + tests/test_classicapi.lua | 71 ++ tests/test_combat_auto.lua | 15 + tests/test_factor.lua | 128 +++ tests/test_hitch.lua | 232 +++++ tests/test_memory.lua | 154 +++ tests/test_raid.lua | 242 +++++ tests/test_stock.lua | 133 +++ 11 files changed, 2124 insertions(+) create mode 100644 !!!!GentleGC_Memory/!!!!GentleGC_Memory.toc create mode 100644 !!!!GentleGC_Memory/GentleGC_Memory.lua create mode 100644 GentleGC.lua create mode 100644 GentleGC.toc create mode 100644 tests/test_classicapi.lua create mode 100644 tests/test_combat_auto.lua create mode 100644 tests/test_factor.lua create mode 100644 tests/test_hitch.lua create mode 100644 tests/test_memory.lua create mode 100644 tests/test_raid.lua create mode 100644 tests/test_stock.lua 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")