2026-10-30 16:45:52 +00:00
2026-10-30 16:45:52 +00:00

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")

S
Description
Run garbage collection without pause-the-world allocation
Readme
292 KiB
Languages
Lua 99.7%
Shell 0.3%