fix(maps): Prevent use-after-free crash in Map::SendObjectUpdates

Three-layer defense against dangling pointers in _updateObjects:

1. BotWorldSessionMgr: Call SetDestroyedObject(true) early in
   RemovePlayerBot() before clearing update mask

2. BaseEntity: Block re-adding to _updateObjects once marked destroyed
   by checking !m_isDestroyedObject in AddToObjectUpdateIfNeeded()

3. Map: Add IsDestroyedObject() check before BuildUpdate() as final
   safety net for partially corrupted objects

Also fix BG bot count calculation in QueueStatePoller to use
maxPlayersPerTeam instead of minPlayersPerTeam (15v15 for SotA
instead of 5v5)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Signed-off-by: luis <[email protected]>
This commit is contained in:
agatho
2026-02-03 21:29:55 -03:00
committed by luis
co-authored by Claude Opus 4.5
parent df8bea884e
commit e136047f18
4 changed files with 41 additions and 14 deletions
@@ -462,13 +462,14 @@ void QueueStatePoller::DoPollBGQueue(BattlegroundTypeId bgTypeId, BattlegroundBr
snapshot.hordeCount = hordeCount;
snapshot.minPlayersPerTeam = minPlayers;
snapshot.maxPlayersPerTeam = maxPlayers;
snapshot.allianceShortage = static_cast<int32>(minPlayers) - static_cast<int32>(allianceCount);
snapshot.hordeShortage = static_cast<int32>(minPlayers) - static_cast<int32>(hordeCount);
// Use maxPlayers for shortage calculation - we want to fill the BG, not just meet minimum
snapshot.allianceShortage = static_cast<int32>(maxPlayers) - static_cast<int32>(allianceCount);
snapshot.hordeShortage = static_cast<int32>(maxPlayers) - static_cast<int32>(hordeCount);
snapshot.timestamp = time(nullptr);
TC_LOG_DEBUG("playerbot.jit", "QueueStatePoller: BG Poll - Type={} Bracket={} Alliance={}/{} Horde={}/{} Shortage=A:{}/H:{}",
static_cast<uint32>(bgTypeId), static_cast<uint32>(bracket),
allianceCount, minPlayers, hordeCount, minPlayers,
allianceCount, maxPlayers, hordeCount, maxPlayers,
snapshot.allianceShortage, snapshot.hordeShortage);
// Process shortage if detected
@@ -399,6 +399,19 @@ void BotWorldSessionMgr::RemovePlayerBot(ObjectGuid playerGuid)
TC_LOG_INFO("module.playerbot.session", "Queuing bot for removal (name unavailable)");
}
// CRITICAL FIX (Map.cpp:1968 use-after-free crash):
// Mark bot as destroyed BEFORE clearing update mask to prevent re-adding to _updateObjects.
//
// Problem: After ClearUpdateMask(true) removes bot from _updateObjects, bot AI continues
// running and may modify properties. Property setters call AddToObjectUpdateIfNeeded()
// which re-adds the bot to _updateObjects. When bot is finally destroyed during logout,
// MapUpdater worker threads find a dangling pointer -> ACCESS_VIOLATION.
//
// Solution: Set m_isDestroyedObject=true FIRST. This blocks AddToObjectUpdateIfNeeded()
// from ever re-adding the bot to _updateObjects (check added in BaseEntity.cpp).
player->SetDestroyedObject(true);
TC_LOG_DEBUG("module.playerbot.session", "Marked bot {} as destroyed to prevent _updateObjects re-add", playerGuid.ToString());
// CRITICAL FIX (Cell::Visit crash - CellImpl.h:65):
// Clear visibility notification flags BEFORE queuing for removal.
//
@@ -636,7 +636,13 @@ UF::UpdateFieldFlag BaseEntity::GetUpdateFieldFlagsFor(Player const* /*target*/)
void BaseEntity::AddToObjectUpdateIfNeeded()
{
if (m_inWorld && !m_objectUpdated)
// PLAYERBOT FIX: Also check m_isDestroyedObject to prevent re-adding to _updateObjects
// after an object is marked for destruction but before actual deletion.
// Race condition: RemovePlayerBot() marks bot for removal, ClearUpdateMask(true) removes
// from _updateObjects, but bot AI continues running and modifies properties. Without this
// check, property setters would re-add the bot to _updateObjects, creating a dangling
// pointer when the bot is finally destroyed.
if (m_inWorld && !m_objectUpdated && !m_isDestroyedObject)
m_objectUpdated = AddToObjectUpdate();
}
+17 -10
View File
@@ -1970,18 +1970,25 @@ void Map::SendObjectUpdates()
BaseEntity* obj = *_updateObjects.begin();
_updateObjects.erase(_updateObjects.begin());
// PLAYERBOT FIX: Graceful skip instead of ASSERT for race condition prevention
// Race condition in BaseEntity::RemoveFromWorld():
// 1. m_inWorld = false (FIRST)
// 2. ClearUpdateMask(true) -> RemoveFromObjectUpdate() (SECOND)
// There's a window between these two operations where:
// - Object is still in _updateObjects (not yet removed)
// - But IsInWorld() returns false (already set to false)
// If Map::SendObjectUpdates runs during this window, the ASSERT would crash.
// Solution: Skip objects that are not in world - they don't need updates anyway.
// PLAYERBOT FIX: Multiple safety checks for race condition prevention
//
// Race condition scenarios:
// 1. BaseEntity::RemoveFromWorld() sets m_inWorld=false before ClearUpdateMask removes from set
// 2. Bot marked for removal (SetDestroyedObject) but still in _updateObjects due to re-add
// 3. Object freed but memory not yet overwritten - partial corruption
//
// Check 1: Skip objects not in world
if (!obj->IsInWorld())
{
TC_LOG_DEBUG("maps", "Map::SendObjectUpdates: Skipping object not in world (race condition prevention)");
TC_LOG_DEBUG("maps", "Map::SendObjectUpdates: Skipping object not in world");
continue;
}
// Check 2: Skip objects marked for destruction (prevents use-after-free)
// This catches objects that passed IsInWorld() but are being destroyed
if (obj->IsDestroyedObject())
{
TC_LOG_DEBUG("maps", "Map::SendObjectUpdates: Skipping destroyed object");
continue;
}