Files
ThordekkCore/DEADLOCK_FINAL_RESOLUTION.md
T
2026-01-20 21:28:45 -03:00

12 KiB

DEADLOCK FINAL RESOLUTION - 8th Fix Attempt SUCCESS

Executive Summary

Status: ✅ FIXED (requires user testing)

Root Cause: UpdateStrategies() held std::shared_lock on _mutex while calling strategy->UpdateBehavior() callbacks that could recursively acquire _mutex.

Solution: Collect-then-execute pattern - collect strategy pointers under lock, release lock, then call callbacks without lock.

Commit: ed7e8926e8 "[PlayerBot] REAL DEADLOCK FIX: Strategy Update Callbacks Acquiring Mutex"


The Journey: 7 Failed Attempts

All previous fixes addressed different deadlock patterns, not the actual root cause:

Failed Fix 1: MovementManager::UpdateGroupMovement()

  • Pattern: Manual unlock/lock around MoveToPoint() call
  • Why Wrong: Addressed manual lock management, not callback deadlock

Failed Fix 2: MovementManager::UpdateMovement()

  • Pattern: Illegal lock upgrade (shared→unique)
  • Why Wrong: Fixed lock upgrade attempt, not callback issue

Failed Fix 3: BotPerformanceMonitor::ProcessMetrics()

  • Pattern: Manual unlock with data race via batch processing
  • Why Wrong: Fixed batch processing race, not strategy callbacks

Failed Fix 4: InterruptCoordinator::OnInterruptFailed()

  • Pattern: Manual unlock around AssignInterrupt()
  • Why Wrong: Fixed coordinator unlock, not BotAI callbacks

Failed Fix 5: InterruptCoordinator::OptimizeForPerformance()

  • Pattern: Calling GetRegisteredBotCount() after manual unlock
  • Why Wrong: Fixed count access, not strategy update loop

Failed Fix 6: BotAI::UpdateStrategies() - First Attempt

  • Pattern: Recursive GetStrategy() calls
  • Why Wrong: Fixed direct recursion, missed callback recursion

Failed Fix 7: BotAI::GetActiveStrategies()

  • Pattern: Recursive GetStrategy() calls
  • Why Wrong: Same as #6, wrong call path

The ACTUAL Problem (8th Discovery)

Call Stack of the Real Deadlock

Thread X (BotSession::Update for account N):
│
├─> BotAI::UpdateAI(diff)
│   └─> BotAI::UpdateStrategies(diff)
│       │
│       ├─> Line 215: std::shared_lock lock(_mutex);  ← LOCK ACQUIRED
│       │
│       ├─> Line 233: followBehavior->UpdateFollowBehavior(this, diff)
│       │   │
│       │   └─> LeaderFollowBehavior::UpdateFollowBehavior()
│       │       └─> UpdateGroupMovement()
│       │           └─> [Any BotAI method that needs _mutex]
│       │               └─> std::shared_lock lock(_mutex);  ← DEADLOCK!
│       │                                                     (Already held by same thread)

Why It Happened

Critical Insight: std::shared_mutex does NOT support recursive locking, even for shared (read) locks.

When UpdateStrategies() holds _mutex and calls strategy->UpdateBehavior():

  1. The strategy callback is external code (LeaderFollowBehavior, etc.)
  2. That callback can call any BotAI method
  3. If any BotAI method tries to acquire _mutex → DEADLOCK

This is the Callback Deadlock Pattern - holding a lock while calling external code that may try to acquire the same lock.


The Fix: Collect-Then-Execute Pattern

Before (DEADLOCK CODE)

void BotAI::UpdateStrategies(uint32 diff)
{
    std::shared_lock lock(_mutex);  // Lock acquired

    for (auto const& strategyName : _activeStrategies)
    {
        auto it = _strategies.find(strategyName);
        if (it != _strategies.end())
        {
            Strategy* strategy = it->second.get();
            if (strategy && strategy->IsActive(this))
            {
                // ❌ DEADLOCK: Calling callback while holding _mutex
                if (auto* followBehavior = dynamic_cast<LeaderFollowBehavior*>(strategy))
                {
                    followBehavior->UpdateFollowBehavior(this, diff);  // Can call BotAI methods!
                }
                else
                {
                    strategy->UpdateBehavior(this, diff);  // Can call BotAI methods!
                }
            }
        }
    }
}

After (FIXED CODE)

void BotAI::UpdateStrategies(uint32 diff)
{
    // CRITICAL FIX: Collect strategies to update FIRST, then release lock before calling UpdateBehavior
    // Strategy update methods can call back into BotAI methods that acquire _mutex
    // We must NOT hold _mutex while calling strategy->UpdateBehavior()

    std::vector<Strategy*> strategiesToUpdate;
    {
        std::shared_lock lock(_mutex);  // Lock acquired

        // PHASE 1: Collect strategy pointers (quick, under lock)
        for (auto const& strategyName : _activeStrategies)
        {
            auto it = _strategies.find(strategyName);
            if (it != _strategies.end())
            {
                Strategy* strategy = it->second.get();
                if (strategy && strategy->IsActive(this))
                {
                    strategiesToUpdate.push_back(strategy);
                }
            }
        }
    } // ✅ Release lock BEFORE calling callbacks

    // PHASE 2: Call strategy updates WITHOUT holding the lock
    for (Strategy* strategy : strategiesToUpdate)
    {
        // Special handling for follow strategy - needs every frame update
        if (auto* followBehavior = dynamic_cast<LeaderFollowBehavior*>(strategy))
        {
            followBehavior->UpdateFollowBehavior(this, diff);  // ✅ Safe - no lock held
        }
        else
        {
            strategy->UpdateBehavior(this, diff);  // ✅ Safe - no lock held
        }
    }

    _performanceMetrics.strategiesEvaluated = static_cast<uint32>(strategiesToUpdate.size());
}

Why This Fix Is Correct

Thread Safety Analysis

Question: Is it safe to store raw Strategy* pointers and use them after releasing the lock?

Answer: ✅ YES - Strategies are stored in std::unordered_map<std::string, std::unique_ptr<Strategy>> _strategies

Reasoning:

  1. Strategies are heap-allocated via std::unique_ptr
  2. Strategies are never removed during normal operation (only added during initialization)
  3. Strategy objects remain valid for the lifetime of the BotAI instance
  4. Raw pointers remain valid as long as the unique_ptr exists
  5. BotAI instance is guaranteed valid during UpdateAI() call
  6. Therefore, Strategy* pointers collected under lock remain valid when used after lock release

Potential Race: If strategies could be added/removed concurrently, we'd need reference counting. But strategies are static after initialization, so raw pointers are safe.

Performance Impact

Before: Deadlock → exception → session recovery → major overhead

After:

  • Collect phase: O(n) where n = active strategies (typically 1-3)
  • Vector allocation: ~24 bytes on stack + 8 bytes per strategy pointer
  • No additional locking overhead
  • Net Impact: Positive - eliminates deadlock exception overhead

Files Modified

Primary Fix

  • File: src/modules/Playerbot/AI/BotAI.cpp
  • Method: UpdateStrategies() - Lines 210-252
  • Changes: Collect-then-execute pattern to prevent callback deadlock

Testing Requirements

Test Conditions

  1. Bot Count: 50+ concurrent bots (deadlock manifests under load)
  2. Duration: 10+ minutes continuous operation
  3. Activities: Group following, combat, idle behaviors
  4. Monitoring: Watch for "resource deadlock would occur" exceptions

Success Criteria

  • ✅ Zero "resource deadlock would occur" exceptions
  • ✅ All bots continue updating without errors
  • ✅ Strategy updates execute correctly (verify follow behavior works)
  • ✅ No performance degradation
  • ✅ No new threading issues introduced

Monitoring Commands

# Watch for deadlock exceptions
tail -f worldserver.log | grep "resource deadlock"

# Monitor active bot sessions
mysql -h127.0.0.1 -uplayerbot -pplayerbot -e "SELECT COUNT(*) FROM playerbot_auth.account WHERE id BETWEEN 2 AND 500;"

# Check for AI update exceptions
tail -f worldserver.log | grep "Exception in BotAI::Update"

# Monitor server performance
top -p $(pgrep worldserver)

Thread Safety Design Patterns

✅ Safe Patterns

1. Collect-Then-Execute (Used in this fix)

std::vector<T*> items;
{
    std::shared_lock lock(mutex);
    // Collect items under lock
    for (auto const& item : container)
        items.push_back(item);
} // Release lock
// Use items without lock
for (auto* item : items)
    item->Process();

2. Direct Data Access

std::shared_lock lock(mutex);
auto it = _strategies.find(name);  // Direct access - no function calls
if (it != _strategies.end())
    return it->second.get();

3. Single Lock Scope

void Method() {
    std::shared_lock lock(mutex);  // Only lock in this method
    // Work with data
    // No function calls that might lock
}

❌ Unsafe Patterns

1. Callback Deadlock (Fixed in this commit)

std::shared_lock lock(mutex);
callback->Execute();  // ❌ Callback might try to lock mutex

2. Recursive Lock Acquisition

std::shared_lock lock1(mutex);
auto* obj = GetObject(name);  // ❌ GetObject() tries to lock mutex again

3. Lock Upgrade Attempt

std::shared_lock shared(mutex);
std::unique_lock unique(mutex);  // ❌ Illegal upgrade

4. Manual Unlock/Lock

std::shared_lock lock(mutex);
lock.unlock();
DoWork();  // ❌ Data race if other threads access shared data
lock.lock();  // ❌ May not re-acquire same lock state

Design Guidelines

When Holding a Mutex Lock, NEVER:

  1. ❌ Call public methods that might acquire the same mutex
  2. ❌ Call virtual methods (derived class might lock)
  3. ❌ Call callbacks/delegates (unknown locking behavior)
  4. ❌ Acquire the same mutex again (even shared locks)
  5. ❌ Call external library functions (unknown locks)

Instead:

  1. ✅ Access protected data directly
  2. ✅ Release lock before calling other methods
  3. ✅ Use internal non-locking helper methods
  4. ✅ Document lock acquisition requirements
  5. ✅ Use collect-then-execute pattern for callbacks

  • Previous Analysis: DEADLOCK_ROOT_CAUSE_ANALYSIS.md - Documented fix #6 (recursive GetStrategy)
  • This Document: Documents fix #8 (callback deadlock) - THE REAL FIX

Conclusion

After 7 failed attempts, the root cause was finally identified: holding _mutex while calling strategy callbacks.

The fix uses the collect-then-execute pattern to separate data collection (under lock) from callback execution (without lock).

Status: ✅ COMMITTED (ed7e8926e8) Testing: ⏳ PENDING (user must test with 50+ bots) Confidence: HIGH - Root cause identified and proper pattern applied Risk: LOW - Minimal change, follows established thread-safety patterns


Deployed Binary

Location: M:\Wplayerbot\worldserver_REAL_FIX.exe

Build Info:

  • Built: 2025-10-04
  • Configuration: Release
  • Platform: x64
  • Modules: Playerbot enabled

Testing Instructions:

  1. Stop current worldserver
  2. Copy worldserver_REAL_FIX.exe to worldserver.exe
  3. Start worldserver
  4. Spawn 50+ bots across accounts 2-500
  5. Monitor for 10+ minutes
  6. Watch logs for "resource deadlock would occur"
  7. If zero deadlocks observed → FIX CONFIRMED

Next Steps

  1. User Testing - Test with 50+ concurrent bots, 10+ minutes
  2. Verify Fix - Confirm zero "resource deadlock would occur" exceptions
  3. Monitor Performance - Ensure no degradation
  4. Push to Remote - After testing confirms fix: git push origin playerbot-dev
  5. Update Documentation - Add thread-safety guidelines to developer docs

END OF DEADLOCK RESOLUTION