Skip to content

Fix two unbounded-growth paths in copyTableSafe and SwitchAttributeNode - #2565

Open
p11primera wants to merge 2 commits into
PathOfBuildingCommunity:devfrom
p11primera:fix/shared-table-copy-growth
Open

p11primera wants to merge 2 commits into
PathOfBuildingCommunity:devfrom
p11primera:fix/shared-table-copy-growth

Conversation

@p11primera

Copy link
Copy Markdown

What

Two independent memory-growth fixes found while profiling repeated tree allocate/deallocate cycles. Both manifest the same way — Lua heap growing unbounded until a copyTableSafe memo write throws table overflow (LuaJIT LJ_MAX_HBITS, hard 2^26 hash-node limit) and the post-error full GC stalls for minutes.

1. copyTableSafe — abandoned copies leaked their memo (Common.lua)

The sub-table memo lived in a module-level table that was only wipeTable'd on the success path of the outermost call. Any copy that never returned — a coroutine suspended mid-copy and later dropped, or a copy aborted by an error — left its memo populated permanently, pinning the whole source sub-tree plus every mirror table already built. Thread the memo through the recursion as a parameter so it is created by the outermost call and garbage-collected the moment that call returns, including the abandoned/error paths. Success-path behaviour is unchanged (shared sub-tables still collapse to one copy via memo identity).

2. SwitchAttributeNode — re-switches doubled the shared node every call (PassiveSpec.lua)

Each call deep-copied the shared passive-tree node and attached a new mirror generation (ReplaceNode + ProcessStats); BuildAllDependsAndPaths then projects that override back onto the shared node. So every re-switch doubles the node's reachable graph. Repeated allocate/deallocate over an attribute node — weapon-set switching, build import, tree reload while overrides are retained — accumulate generations until one copy crosses the 2^26 limit.

Two guards:

  1. Idempotence — record _attrOptionIndex on the override; no-op when re-switching to the same option.
  2. Generation cache — build at most one mirror per (nodeId, option); when the override map is reset (import/class-change wipe hashOverrides, DeallocNode nils entries) re-attach the same cached object, so a shared node holds at most one generation per option. ReplaceNode/ProcessStats only re-project shared nodes from these overrides (idempotent re-projection), so reusing the object across specs is safe.

Reproduction / evidence

Diagnosed on the tree-optimizer branch (which drives thousands of alloc/dealloc cycles in droppable coroutines) with sample/vmmap/in-process probes on macOS arm64 + Linux:

  • Leaked memo pinned 11–41 GB heap; all hot stacks were lj_tab_* / GC frames, 0 trace/mcode frames.
  • Runaway copy caught with memo = 67108864 (= 2^26 exactly) while the widest table copied had only 30 keys; caller was SwitchAttributeNode in every capture. Post-memo-fix failure rate was still ~24%; the generation cache is what closes it.

The fixes are self-contained to these two files and do not depend on the optimizer. Happy to split into two PRs if you'd prefer one fix each.

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.
…ions

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant