fix: release Lua-pushed KV userdata to stop ScopedKV memory leak - #799
Merged
Merged
Conversation
…ores The "KV" Lua metatable was registered with `registerClass`, which does not install the `__gc` metamethod. However, KV objects are pushed to Lua as owning `std::shared_ptr<KV>` through `pushUserdata` (a placement-new that bumps the shared_ptr refcount). Without `__gc` -> `luaGarbageCollection` -> `reset()`, the shared_ptr destructor never runs when Lua collects the userdata, so the strong reference is never released. As a result, every `kv.scoped()`, `scopedKV:scoped()` and `player:kv()` call leaked a `ScopedKV` instance (plus its control block and prefix string). Because KV is used pervasively from Lua (login scripts, per-tick player events, combat, NPCs, hireling, features, etc.), memory grew on every login/relog and during normal play. Switch to `registerSharedClass`, which installs `__gc = luaGarbageCollection`, matching the pattern used by every other shared_ptr-backed Lua class (Player, Item, Container, Creature, Monster, Npc, Guild). Only freshly-created `ScopedKV` instances are ever pushed as KV userdata; the root `KVStore` singleton is accessed via the global `kv` table and is never wrapped in a pushed shared_ptr, so `reset()` only drops the Lua-held reference and cannot double-free the singleton.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
KVLua metatable is registered withregisterClass, which does not install a__gcmetamethod. ButKVobjects are pushed to Lua as owningstd::shared_ptr<KV>, so without__gcthe shared_ptr is never released when Lua collects the userdata. Everykv.scoped(),scopedKV:scoped()andplayer:kv()call therefore leaks aScopedKV(plus its control block and prefix string). KV is used on many hot Lua paths, so memory grows on every login/relog and during normal play.The fix is a one-liner: register the metatable with
registerSharedClassinstead ofregisterClass, so it installs the already-existing__gchandler used by every other shared_ptr-backed Lua class.Root cause
src/lua/functions/core/libs/kv_functions.cppregisters the metatable without GC:KVvalues are pushed to Lua through the owningshared_ptroverload ofpushUserdata(src/lua/functions/lua_functions_loader.hpp), which placement-news astd::shared_ptr<KV>into the Lua userdata and bumps the refcount:That refcount is only dropped by the
__gcmetamethod (src/lua/functions/lua_functions_loader.cpp):and
__gcis only installed byregisterSharedClass:Because
KVusedregisterClass, the placement-new'dstd::shared_ptr<KV>destructor never runs when Lua collects the userdata. The strong reference leaks, so each pushedScopedKV— allocated byKVStore::scoped()/ScopedKV::scoped()and byPlayer::kv()(g_kv().scoped("player")->scoped(<guid>)) — is never freed.This is the only class in this situation: other plain
registerClasstypes (ItemType,ItemClassification,Imbuement,Variant) are pushed as non-owning raw pointers (or, forVariant, a plain Lua table) and read back withgetUserdata<T>(T**), so they take no ownership and need no__gc.KVis the onlyregisterClasstype that pushes an owningshared_ptr.Impact
The KV API is pushed to Lua on hot paths —
player:kv()indata/scripts/creaturescripts/player/login.lua, per-tick player/creature events (data/events/scripts/player.lua,creature.lua), combat protection, NPC modules, hireling, features, achievements, daily reward and talkactions. Each call leaks a small heap allocation that is never reclaimed, so process RSS grows continuously — most visibly when players relog repeatedly. Systems that persist a lot of per-player state through KV (forge, wheel, proficiency, hireling, …) make the growth most apparent under profiling, but the leak lives in the KV Lua binding, not in those systems.Fix
This installs
__gc = luaGarbageCollectionon theKVmetatable, matchingPlayer,Item,Container,Creature,Monster,Npc,Guild, etc.Why it is safe
ScopedKVinstances are ever pushed asKVuserdata (returned byscoped()andPlayer::kv()). The rootKVStoresingleton is reached through the globalkvtable (kv.get,kv.set, …) and is never wrapped in a pushedshared_ptr, soreset()only drops the Lua-held reference and cannot double-free or destroy the singleton.luaGarbageCollectionreinterprets the userdata asstd::shared_ptr<SharedObject>*and callsreset(). This is the exact mechanism already used by every shared class:std::shared_ptr<T>layout is independent ofT, and the control block's type-erased deleter destroys the realScopedKV.get/set/scoped/keys/remove;__gconly releases the C++ reference at collection time.Testing
kv.scoped(...),scopedKV:get/set/keys/removeandplayer:kv()keep working unchanged.collectgarbage("collect")now keeps RSS stable instead of growing without bound; under ASan/valgrind the leakedScopedKV/ control-block allocations are gone.