Port the production hardening and performance fixes tested on Avalorium into Canary. This change fixes Lua shared-userdata ownership issues in which std::shared_ptr<T> values stored inside Lua userdata could leak or use the wrong finalizer when the assigned metatable did not match the underlying C++ type. It also reduces avoidable shared_ptr copies and shared_from_this() churn in movement, spectator, and monster hot paths. Lua userdata fixes: - Add typed Lua shared-userdata helpers through LuaUserdataTraits<T>. - Add Lua::registerSharedClass<T>. - Add Lua::pushSharedUserdata<T>. - Add Lua::pushBorrowedSharedUserdata<T>. - Add typed Lua::luaSharedPtrGarbageCollection<T>. - Migrate KV, Condition, and NetworkMessage to the typed shared-userdata path. - Migrate high-priority non-SharedObject bindings away from the legacy generic finalizer path. - Deprecate old untyped shared userdata helpers so new bindings are less likely to repeat the same ownership bug. - Document the Lua shared-userdata ownership contract and add AGENTS.md review guidance for future binding changes. Critical leak cases fixed: - KV values returned by kv:scoped() and player:kv() now use typed cleanup. - Condition values returned by creature:getCondition() now release the stored std::shared_ptr<Condition> correctly. - Borrowed NetworkMessage callback userdata now uses a no-op deleter and typed userdata cleanup instead of an invalid ownership path. Additional bindings migrated: - revscripts/events - Combat - ModalWindow - Town - Guild - Vocation - Group - Mount - Shop - Loot - MonsterSpell - MonsterType - Spell - Weapon - Charm - Zone - BatchUpdate Movement and spectator hot paths: - Add raw creature type accessors for cheaper type checks without shared_from_this(). - Reduce movement/remove notification refcount churn by using local Player* vectors anchored by the existing strong Spectators snapshot. - Preserve client index semantics with getClientIndexOfCreature to avoid stack-high ghost or desync regressions. - Avoid unnecessary self shared_ptr construction in Monster hot paths. Safety: - Does not change general game ownership. - Does not convert Spectators or cache storage to raw pointers. - Keeps raw Player* values local only, lifetime-anchored by the Spectators' snapshot in the same scope. - Uses a no-op deleter for borrowed NetworkMessage userdata. - Leaves polymorphic core userdata such as Creature, Player, Monster, Npc, Item, Container, Tile, and Teleport for a separate audit. - Keeps Position on the legacy helper because it is represented as a Lua table with a metatable, not as shared userdata. Validation: - Ran git diff --check. - Built Windows release with cmake --preset windows-release. - Built target canary with cmake --build --preset windows-release --target canary. - Verified critical legacy patterns are gone with grep checks. - Verified migrated non-SharedObject bindings no longer use the old shared_ptr pushUserdata plus setMetatable pattern. - Reviewed before/after profiling from the live-server hotspot context. This improves the correctness of Lua bindings, reduces shared_ptr lifetime risks, and lowers refcount churn in movement/spectator paths without changing gameplay ownership semantics.
5.9 KiB
Lua shared userdata ownership
Canary Lua bindings can expose C++ objects as full userdata. When the userdata
stores a std::shared_ptr<T>, the userdata memory contains a non-trivial C++
object that was constructed with placement-new. Lua owns the userdata memory,
but it does not know how to destroy the C++ std::shared_ptr<T> object unless
the userdata metatable has a matching __gc finalizer.
What went wrong
The dangerous pattern is:
Lua::pushUserdata<T>(L, sharedPtr);
Lua::setMetatable(L, -1, "TypeName");
pushUserdata<T>(..., std::shared_ptr<T>) constructs a std::shared_ptr<T>
inside Lua userdata. If the metatable does not register __gc, Lua can collect
the userdata block without running the std::shared_ptr<T> destructor. The
reference count is never decremented, the control block remains allocated, and
the pointed object can stay alive permanently.
This is especially easy to miss because collectgarbage("collect") may keep
Lua memory stable while process RSS keeps growing in the C++ heap.
The bug was observed in these high-risk bindings:
KV:kv:scoped()andplayer:kv()returned shared userdata with no typed finalizer, so scoped KV objects could be retained indefinitely.Condition:creature:getCondition()returned shared userdata through a weak metatable.setWeakMetatableremoves__gc; it does not store a realstd::weak_ptr.NetworkMessage: module receive-byte callbacks wrapped a borrowedNetworkMessage&instd::shared_ptr<NetworkMessage>(&msg). Without a no-op deleter and typed finalizer, this could leak the control block and madenetworkMessage:delete()unsafe for borrowed messages.
Current contract
Shared userdata must use the typed helpers in
src/lua/functions/lua_functions_loader.hpp:
template <>
struct LuaUserdataTraits<MyType> {
static constexpr std::string_view name = "MyType";
};
Lua::registerSharedClass<MyType>(L, "", MyTypeFunctions::luaMyTypeCreate);
Lua::pushSharedUserdata<MyType>(L, mySharedPtr);
The trait is intentionally required. It keeps the C++ type, Lua metatable name,
and __gc finalizer tied together at compile time.
For borrowed callback objects, use:
Lua::pushBorrowedSharedUserdata<MyType>(L, borrowedObject);
This creates a std::shared_ptr<T> with a no-op deleter and still uses the
typed __gc finalizer to destroy only the std::shared_ptr<T> stored in the
userdata. It prevents invalid delete and releases the control block. It does
not make the borrowed object safe to store after the callback returns.
Rules
- Do not combine
pushUserdata<T>(..., std::shared_ptr<T>)with a manualsetMetatable. - Do not use
setWeakMetatablefor userdata that storesstd::shared_ptr<T>. It disables__gcand can leak the stored C++ object. - Do not wrap borrowed objects with
std::shared_ptr<T>(&object)unless a no-op deleter is used. PreferpushBorrowedSharedUserdata<T>. - Do not register new shared userdata with the untyped
registerSharedClass(lua_State*, className, baseClass, ctor)overload. PreferregisterSharedClass<T>. - Add a
LuaUserdataTraits<T>specialization before pushing or registering a new shared userdata type. - Use
pushSharedUserdata<T>only for non-conststd::shared_ptr<T>. If a Lua API needs a read-only object, introduce a separate read-only metatable instead of pushingstd::shared_ptr<const T>through the normal mutable metatable.
Review checklist
When reviewing Lua binding changes, check for:
rg -n "pushUserdata<.*std::shared_ptr|setWeakMetatable|std::shared_ptr<[^>]+>\\(&" src/lua
rg -n "registerSharedClass\\(L," src/lua
Any match must be justified. New shared userdata should normally use
LuaUserdataTraits<T>, registerSharedClass<T>, pushSharedUserdata<T>, or
pushBorrowedSharedUserdata<T>.
What this PR fixes
The typed shared userdata helpers make the finalizer use the real C++ type:
auto objPtr = static_cast<std::shared_ptr<T>*>(lua_touserdata(L, 1));
std::destroy_at(objPtr);
std::construct_at(objPtr);
That runs the std::shared_ptr<T> destructor, decrements the reference count,
and then leaves an empty std::shared_ptr<T> in the userdata slot. Rebuilding
the empty value mirrors the existing defensive pattern used by other userdata
cleanup code and reduces the impact of accidental repeated cleanup.
The PR also migrates the critical KV, Condition, and NetworkMessage
bindings to the typed path.
Follow-up hardening
After the critical leak fixes, the next risky pattern was the legacy shared class registration:
Lua::registerSharedClass(L, "TypeName", "", TypeFunctions::luaCreate);
That overload installs Lua::luaGarbageCollection, which treats the userdata as
std::shared_ptr<SharedObject>. This is not correct for userdata that actually
stores std::shared_ptr<T> where T does not inherit from SharedObject.
The non-SharedObject shared userdata bindings were migrated to typed
finalizers as well:
ActionBatchUpdateCharmCombatCreatureEventEventCallbackGlobalEventGroupGuildLootModalWindowMonsterSpellMonsterTypeMountShopSpellTalkActionTownVocationWeaponZone
Some legacy registerSharedClass(L, ...) calls can still exist for userdata
whose stored type is part of the SharedObject hierarchy, and Position is a
Lua table rather than a shared userdata object. New code should still prefer the
typed helpers.
Do not mechanically migrate polymorphic core userdata such as Creature,
Player, Monster, Npc, Item, Container, Tile, or Teleport without a
separate audit. Those paths can push a userdata that stores a base
std::shared_ptr<T> and then assign a more specific Lua metatable. A typed
finalizer is only correct when the finalizer type matches the actual
std::shared_ptr<T> object stored in the userdata slot.