From 04abde4f2e6432497353fb6f927669cb12fe8795 Mon Sep 17 00:00:00 2001 From: p11primera <66186628+p11primera@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:03:08 +0000 Subject: [PATCH 1/2] fix(common): per-call memo in copyTableSafe to stop abandoned-copy leaks copyTableSafe kept its sub-table memo in a module-level table that was only wipedTable'd on the SUCCESS path of the outermost call. Any copy that never returned -- a coroutine abandoned while suspended mid-copy, or a copy aborted by an error -- left its memo populated forever, pinning the entire source sub-tree plus every mirror already built. Across a session these leaked memos grow the Lua heap unbounded until a memo write throws 'table overflow' (LuaJIT LJ_MAX_HBITS) and the post-error full GC stalls for minutes. Thread the memo through the recursion as a parameter so it is created by the outermost call and becomes garbage the moment that call returns, including the abandoned/error paths. Behaviour on the success path is unchanged: shared sub-tables still collapse to a single copy via memo identity within one copy operation. --- src/Modules/Common.lua | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index cef84e00d8..1741f1aa88 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -504,10 +504,19 @@ function copyTable(tbl, noRecurse) return out end do - local subTableMap = { } - function copyTableSafe(tbl, noRecurse, preserveMeta, isSubTable) + -- The sub-table memo is threaded through the recursion as a parameter + -- rather than kept in a module-level table: it is created by the + -- outermost call and becomes garbage as soon as that call returns -- + -- INCLUDING when it never returns (a copy abandoned while its coroutine + -- is suspended, or aborted by an error mid-copy). A module-level memo + -- was only wiped on the success path, so any abandoned copy pinned its + -- entire source sub-tree (and every mirror table already copied) for + -- the life of the process, growing the Lua heap until a memo write + -- threw "table overflow" and the post-error full GC ground for minutes. + function copyTableSafe(tbl, noRecurse, preserveMeta, isSubTable, subTableMap) local out = {} if not noRecurse then + subTableMap = subTableMap or { } subTableMap[tbl] = out end if preserveMeta then @@ -515,14 +524,11 @@ do end for k, v in pairs(tbl) do if not noRecurse and type(v) == "table" then - out[k] = subTableMap[v] or copyTableSafe(v, false, preserveMeta, true) + out[k] = subTableMap[v] or copyTableSafe(v, false, preserveMeta, true, subTableMap) else out[k] = v end end - if not noRecurse and not isSubTable then - wipeTable(subTableMap) - end return out end end From f36697fe929e2221b5161315f1c1a0195dddc7a3 Mon Sep 17 00:00:00 2001 From: p11primera <66186628+p11primera@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:03:08 +0000 Subject: [PATCH 2/2] fix(spec): make SwitchAttributeNode idempotent + cache mirror generations Every call deep-copied the SHARED passive-tree node and attached a brand-new mirror generation (ReplaceNode + ProcessStats); BuildAllDepends AndPaths then projects that override back onto the shared node. So each re-switch DOUBLES the node's reachable graph. Repeated allocate/ deallocate cycles over an attribute node -- weapon-set switching, build import, tree reload while overrides are retained -- accumulate generations: copy cost and heap grow exponentially until one copyTableSafe crosses LuaJIT's hard 2^26 hash-node limit and throws 'table overflow'. Two guards: 1. Idempotence -- record _attrOptionIndex on the override and no-op when re-switching to the same option. 2. Generation cache -- build at most one mirror per (nodeId, option) and re-attach the SAME object when the override map is reset (import and class-change wipe hashOverrides; DeallocNode nils entries). A shared node stays at one generation per option. ReplaceNode / ProcessStats only re-project shared nodes FROM these overrides (an idempotent re-projection), so reusing the cached object is safe. --- src/Classes/PassiveSpec.lua | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 154ddf2bbe..d4da3e3116 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -2750,13 +2750,52 @@ end function PassiveSpecClass:SwitchAttributeNode(nodeId, attributeIndex) if self.tree.nodes[nodeId] then --Make sure node exists on current tree + -- Idempotence: already switched to exactly this option => no-op. + -- Without it, every re-switch deep-copies the SHARED tree node and + -- attaches a brand-new mirror generation (ReplaceNode / ProcessStats + -- below); BuildAllDependsAndPaths then projects the override back + -- onto the shared node, so the node's reachable graph doubles with + -- each call. Repeated allocate/deallocate cycles over an attribute + -- node (weapon-set switching, build import, tree reload with + -- retained overrides) accumulate generations: copy cost and Lua heap + -- grow exponentially until a single copyTableSafe hits LuaJIT's hard + -- 2^26 hash-node limit ("table overflow", LJ_MAX_HBITS). + local existing = self.hashOverrides[nodeId] + if existing and existing._attrOptionIndex == attributeIndex then + return + end + -- Build at most ONE mirror generation per (node, option). + -- main:LoadTree caches one PassiveTree per version for the life of + -- the process, while specs swap in fresh hashOverrides maps (import + -- and class change wipe it; DeallocNode nils entries). Re-attaching + -- the SAME cached mirror after such a reset keeps the shared node at + -- one generation per option. ReplaceNode/ProcessStats only ever + -- rewrite shared nodes FROM these overrides (an idempotent + -- re-projection), so reusing the object across specs is safe. + local cache = self.tree._attrSwitchCache + if not cache then + cache = { } + self.tree._attrSwitchCache = cache + end + local byIndex = cache[nodeId] + if not byIndex then + byIndex = { } + cache[nodeId] = byIndex + end + local cached = byIndex[attributeIndex] + if cached then + self.hashOverrides[nodeId] = cached + return + end local newNode = copyTableSafe(self.tree.nodes[nodeId], false, true) if not newNode.isAttribute then return end -- safety check local option = newNode.options[attributeIndex] self:ReplaceNode(newNode, option) self.tree:ProcessStats(newNode) + newNode._attrOptionIndex = attributeIndex + byIndex[attributeIndex] = newNode self.hashOverrides[nodeId] = newNode end end