From cce035f1c390af328206a27d7d7c42a08f37324b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:50:01 -0700 Subject: [PATCH] fix: Removes unnecessary dictionary removal guards (#2565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What `Dictionary.Remove` and `HashSet.Remove` do not bump the collection's version, so removing an entry during a `foreach` does not invalidate the enumerator. A number of loops were still paying for a `PooledRefQueue`/`PooledRefList` to collect keys and drain them in a second pass. This drops those guards. ## Why it's safe Verified against .NET 10.0.10 rather than taken on trust, since the documented guarantee covers only `Dictionary.Remove` while several of these call sites are `HashSet` or enumerate `.Keys`/`.Values`: | Case | Result | |---|---| | `Dictionary` foreach + `Remove` | safe, all entries visited | | `Dictionary.Keys` / `.Values` foreach + `Remove` | safe, all entries visited | | `HashSet` foreach + `Remove` | safe, all entries visited | | `Dictionary` foreach + `Remove` **then `Add`** | throws `InvalidOperationException` | Reflection on `_version` confirms the mechanism: neither `Dictionary.Remove` nor `HashSet.Remove` touches it. Because `Remove` never bumps the version, the `Keys` and `Values` enumerators are just as safe as the dictionary's own, even though only `Dictionary.Remove` documents the behaviour. No entries were skipped in any case. The `HashSet` half is confirmed by [stephentoub on dotnet/dotnet-api-docs#8177](https://github.com/dotnet/dotnet-api-docs/issues/8177#issuecomment-1167251052): *"Both HashSet and Dictionary have been improved to support removal during enumeration. The docs may just benefit from updating."* The gap is in the documentation, not the runtime. `Remove` followed by `Add` in the same enumeration still throws. That is the line this PR does not cross. ## Guards removed `VisibilityList`, `ChampionTitleSystem`, `Channel`, `BombingRun`, `Ruleset`, `PuzzleChest`, `RaceChangeGump`, `StepCache`, `PlayerMurderSystem`, `VirtueSystem`, `ProjectedItem`, `StaminaSystem`, `AIGroupMovement`, `PromotedGuard`, `AutoDenylist`, `LoginAllowlist`, `AntiMacroSystem`, `DetectHidden`. Both collection kinds are covered: `Dictionary` (including loops over `.Keys` and `.Values`) and `HashSet` (`ProjectedItem._active`, `PlayerMurderSystem._contextTerms`, `StaminaSystem._resetHash`). In `StaminaSystem.ResetTimer` the `Count == queue.Count → Clear()` branch goes away with the queue — it only existed to avoid paying for N individual removes. Where the collection supports it, `Contains` + `Remove` and `TryGetValue` + `Remove` also collapse into a single lookup (`if (list.Remove(x))`, `if (m_Pending.Remove(ns, out var state))`). `Utility.Tidy` keeps its two branches: when `K` is serializable the value is not inspected, otherwise the value is. Only the serializable side may be cast, so `Dictionary` and `Dictionary` stay valid. ## Deliberately unchanged **`BaseCreature.LoyaltyTimer.OnTick`** keeps its deferred-delete queue. Removing from `World.Mobiles` while enumerating it is safe, but `Mobile.Delete()` is not a `Remove` — it runs `OnDelete`/`OnAfterDelete`, the `OnParentDeleted` cascade over the creature's pack, `DropHolding()`, and region and guild callbacks. Anything in that surface that constructs a `Mobile` is an `Add` into the dictionary being enumerated, which does invalidate it. `BaseHire.PayTimer.OnTick` has the same shape and is likewise untouched. **Spatial-query buffers** — `GuardedRegion.CallGuards`, `Thunderstorm`, `Exorcism`, `LeverPuzzleController`, `BaseCreature.TeleportPets` — are a different hazard. They buffer the result of a range query because the drain moves or harms mobiles, which mutates sectors mid-enumeration. **Re-entrant drains.** The `_users` sets in `Firebomb` and the explosion, conflagration and confusion-blast potions look like this pattern but are not: the loop collects, `Clear()`s, and only then runs `Target.Cancel` on each, which can re-enter. `AnimalTrainer` enumerates `pm.Stabled` and drains through `RemoveStabled`, which nulls the `Stabled` field once it empties — safe for an in-flight enumerator, which holds the set reference rather than the field, but subtle enough not to be worth inlining on a cold path. ## Verification `dotnet build` clean with 0 warnings; 810 Server and 684 UOContent tests pass. --- Projects/Server/Utilities/Utility.cs | 26 +++++------------ Projects/UOContent/Commands/VisibilityList.cs | 3 +- .../Engines/CannedEvil/ChampionTitleSystem.cs | 9 +----- Projects/UOContent/Engines/Chat/Channel.cs | 16 ++-------- .../Engines/ConPVP/Games/BombingRun.cs | 5 +--- Projects/UOContent/Engines/ConPVP/Ruleset.cs | 3 +- .../UOContent/Engines/Khaldun/PuzzleChest.cs | 9 +----- .../Engines/ML Quests/Gumps/RaceChangeGump.cs | 3 +- .../Engines/Pathing/Cache/StepCache.cs | 8 +---- .../PlayerMurderSystem.cs | 21 +++++--------- .../UOContent/Engines/Virtues/VirtueSystem.cs | 9 +----- .../UOContent/Items/Misc/ProjectedItem.cs | 8 +---- Projects/UOContent/Misc/StaminaSystem.cs | 29 +++---------------- .../Mobiles/AI/BaseAI/AIGroupMovement.cs | 9 +----- .../Network/AutoDenylist/AutoDenylist.cs | 13 +++------ .../Network/Blocklist/PromotedGuard.cs | 8 ++--- .../Network/LoginAllowlist/LoginAllowlist.cs | 23 ++++----------- Projects/UOContent/Skills/AntiMacroSystem.cs | 17 ++--------- Projects/UOContent/Skills/DetectHidden.cs | 9 +----- 19 files changed, 45 insertions(+), 183 deletions(-) diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index f3786d834..9d7cd9f3b 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -1050,28 +1050,16 @@ public static partial class Utility return; } - using var queue = PooledRefQueue.Create(); foreach (var (key, value) in dictionary) { - if (serializableKey) - { - if (key == null || ((ISerializable)key).Deleted) - { - queue.Enqueue(key); - } - } - else - { - if (value == null || ((ISerializable)value).Deleted) - { - queue.Enqueue(key); - } - } - } + var deleted = serializableKey + ? ((ISerializable)key).Deleted + : value == null || ((ISerializable)value).Deleted; - while (queue.Count > 0) - { - dictionary.Remove(queue.Dequeue()); + if (deleted) + { + dictionary.Remove(key); + } } dictionary.TrimExcess(); diff --git a/Projects/UOContent/Commands/VisibilityList.cs b/Projects/UOContent/Commands/VisibilityList.cs index 3e4a0dc2a..ddad258c2 100644 --- a/Projects/UOContent/Commands/VisibilityList.cs +++ b/Projects/UOContent/Commands/VisibilityList.cs @@ -103,9 +103,8 @@ namespace Server.Commands { var list = pm.VisibilityList; - if (list.Contains(targ)) + if (list.Remove(targ)) { - list.Remove(targ); pm.SendMessage($"{targ.Name} has been removed from your visibility list."); } else diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs index d82bfaf15..6160c4595 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs @@ -167,20 +167,13 @@ public class ChampionTitleSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - foreach (var context in _championTitleContexts.Values) { if (!context.CheckAtrophy()) { - queue.Enqueue(context.Player); + _championTitleContexts.Remove(context.Player); } } - - while (queue.Count > 0) - { - _championTitleContexts.Remove((PlayerMobile)queue.Dequeue()); - } } } } diff --git a/Projects/UOContent/Engines/Chat/Channel.cs b/Projects/UOContent/Engines/Chat/Channel.cs index 09b8f6cb3..69f33ddcf 100644 --- a/Projects/UOContent/Engines/Chat/Channel.cs +++ b/Projects/UOContent/Engines/Chat/Channel.cs @@ -146,15 +146,8 @@ namespace Server.Engines.Chat m_Users.Remove(user); user.CurrentChannel = null; - if (m_Moderators.Contains(user)) - { - m_Moderators.Remove(user); - } - - if (m_Voices.Contains(user)) - { - m_Voices.Remove(user); - } + m_Moderators.Remove(user); + m_Voices.Remove(user); SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username); ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel); @@ -183,10 +176,7 @@ namespace Server.Engines.Chat public void RemoveBan(ChatUser user) { - if (m_Banned.Contains(user)) - { - m_Banned.Remove(user); - } + m_Banned.Remove(user); } public void Kick(ChatUser user, ChatUser moderator = null) diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index 29e0b9949..f32168cbf 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -716,10 +716,7 @@ public partial class BRBomb : Item m.Target = new BombTarget(this, m); - if (m_Helpers.Contains(m)) - { - m_Helpers.Remove(m); - } + m_Helpers.Remove(m); if (m_Helpers.Count > 0) { diff --git a/Projects/UOContent/Engines/ConPVP/Ruleset.cs b/Projects/UOContent/Engines/ConPVP/Ruleset.cs index fb4a1ec8b..895496c43 100644 --- a/Projects/UOContent/Engines/ConPVP/Ruleset.cs +++ b/Projects/UOContent/Engines/ConPVP/Ruleset.cs @@ -56,12 +56,11 @@ namespace Server.Engines.ConPVP public void RemoveFlavor(Ruleset flavor) { - if (!Flavors.Contains(flavor)) + if (!Flavors.Remove(flavor)) { return; } - Flavors.Remove(flavor); Options.And(flavor.Options.Not()); flavor.Options.Not(); } diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index a5872e437..4548522b4 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -550,21 +550,14 @@ namespace Server.Items return; } - using var toDelete = PooledRefQueue.Create(); - foreach (var (key, value) in _guesses) { if (Core.Now - value.When > CleanupTime) { - toDelete.Enqueue(key); + _guesses.Remove(key); } } - while (toDelete.Count > 0) - { - _guesses.Remove(toDelete.Dequeue()); - } - if (_guesses.Count == 0) { _guesses = null; diff --git a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs index 6e37f88f5..a0de2461d 100644 --- a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs +++ b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs @@ -116,10 +116,9 @@ namespace Server.Engines.MLQuests.Gumps private static void CloseCurrent(NetState ns) { - if (m_Pending.TryGetValue(ns, out var state)) + if (m_Pending.Remove(ns, out var state)) { state._timeoutToken.Cancel(); - m_Pending.Remove(ns); } ns.SendCloseRaceChanger(); diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index adf178927..b4c5eba7c 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -727,20 +727,14 @@ public sealed class StepCache var window = MissPromotionWindowMs; var beforeCount = _chunkMissTracker.Count; - using var toRemove = PooledRefQueue.Create(); foreach (var kvp in _chunkMissTracker) { if (now - kvp.Value.LastMissTickStamp > window) { - toRemove.Enqueue(kvp.Key); + _chunkMissTracker.Remove(kvp.Key); } } - while (toRemove.Count > 0) - { - _chunkMissTracker.Remove(toRemove.Dequeue()); - } - if (_chunkMissTracker.Count == beforeCount) { _chunkMissTracker.Clear(); diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index b5a1b77d5..d1be82aa9 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -403,27 +403,20 @@ public class PlayerMurderSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - foreach (var context in _contextTerms) { context.DecayKills(); if (!context.CheckStart()) { - queue.Enqueue(context.Player); - } - } - - while (queue.Count > 0) - { - var pm = (PlayerMobile)queue.Dequeue(); - if (_murderContexts.TryGetValue(pm, out var ctx)) - { - if (ctx.CanRemove()) + var pm = context.Player; + if (_murderContexts.TryGetValue(pm, out var ctx)) { - _murderContexts.Remove(pm); + if (ctx.CanRemove()) + { + _murderContexts.Remove(pm); + } + _contextTerms.Remove(ctx); } - _contextTerms.Remove(ctx); } } } diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index 89c5fa3b5..e57a2d386 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -372,8 +372,6 @@ public class VirtueSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - // This is not particularly efficient. If it gets too slow, then use a different architecture. foreach (var (player, virtues) in _playerVirtues) { @@ -381,14 +379,9 @@ public class VirtueSystem : GenericPersistence if (!virtues.IsUsed()) { - queue.Enqueue(player); + _playerVirtues.Remove(player); } } - - while (queue.Count > 0) - { - _playerVirtues.Remove((PlayerMobile)queue.Dequeue()); - } } ~VirtueTimer() diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs index e86025613..03e93d0ea 100644 --- a/Projects/UOContent/Items/Misc/ProjectedItem.cs +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -126,20 +126,14 @@ public partial class ProjectedItem : Item private static void OnTick() { - using var queue = PooledRefQueue.Create(); foreach (var item in _active) { if (!item.SendEffect()) { - queue.Enqueue(item); + _active.Remove(item); } } - while (queue.Count > 0) - { - _active.Remove(queue.Dequeue() as ProjectedItem); - } - if (_active.Count == 0) { _timer?.Stop(); diff --git a/Projects/UOContent/Misc/StaminaSystem.cs b/Projects/UOContent/Misc/StaminaSystem.cs index ed8074cba..fcd5d46b0 100644 --- a/Projects/UOContent/Misc/StaminaSystem.cs +++ b/Projects/UOContent/Misc/StaminaSystem.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Mobiles; using Server.Spells.Ninjitsu; @@ -60,22 +59,16 @@ public static class StaminaSystem EventSink.Logout += Logout; // Credit idle time - using var queue = PooledRefQueue.Create(); foreach (var m in _stepsTaken.Keys) { - // We cannot remove since we are iterating. + // Keeps the ref valid for the check below. ref var stepsTaken = ref RegenSteps(m, out var exists, removeOnInvalidation: false); if (exists && stepsTaken.Steps <= 0) { - queue.Enqueue(m); + _stepsTaken.Remove(m); } } - - while (queue.Count > 0) - { - _stepsTaken.Remove(queue.Dequeue()); - } } [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] @@ -325,7 +318,7 @@ public static class StaminaSystem { var from = e.Mobile; var running = (e.Direction & Direction.Running) != 0; - + if (CannotWalkWhenFatigued && from.Stam <= 0) { from.SendLocalizedMessage(500110); // You are too fatigued to move. @@ -481,27 +474,13 @@ public static class StaminaSystem if (_resetHash.Count > 0) { - using var queue = PooledRefQueue.Create(); - ref var stepsTaken = ref Unsafe.NullRef(); foreach (var m in _resetHash) { stepsTaken = ref GetStepsTaken(m, out var exists); if (!exists || Core.Now >= stepsTaken.IdleStartTime + ResetDuration) { - queue.Enqueue(m); - } - } - - if (_resetHash.Count == queue.Count) - { - _resetHash.Clear(); - } - else - { - while (queue.Count > 0) - { - _resetHash.Remove(queue.Dequeue()); + _resetHash.Remove(m); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index af6c00038..08a745296 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -27,20 +27,13 @@ public abstract partial class BaseAI private static void CleanupReservedPositions() { - using var toRemove = PooledRefQueue.Create(); - foreach (var (m, p) in _reservedPositions) { if (m?.Deleted != false || m.GetDistanceToSqrt(p) < 1) { - toRemove.Enqueue(m); + _reservedPositions.Remove(m); } } - - while (toRemove.Count > 0) - { - _reservedPositions.Remove(toRemove.Dequeue()); - } } private bool UseGroupMovement(Mobile target) => diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs index a55280e1a..3ed36cfe5 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -17,7 +17,6 @@ using System; using System.Collections.Generic; using System.Net; using System.Threading; -using Server.Collections; using Server.Logging; using Server.Network.Bans; @@ -134,22 +133,18 @@ public static class AutoDenylist return; } - using var lapsed = new PooledRefList(16); + var lapsed = 0; foreach (var (address, expires) in _held) { if (expires - nowTicks <= 0) { - lapsed.Add(address); + _held.Remove(address); + lapsed++; } } - for (var i = 0; i < lapsed.Count; i++) - { - _held.Remove(lapsed[i]); - } - - if (lapsed.Count > 0) + if (lapsed > 0) { _warnedFull = false; } diff --git a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs index 4f820b16e..bba8e3ee5 100644 --- a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs +++ b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs @@ -39,17 +39,13 @@ public sealed class PromotedGuard { return; } - using var dead = Collections.PooledRefQueue.Create(); + foreach (var (ip, exp) in _expiry) { if (exp - nowTicks <= 0) { - dead.Enqueue(ip); + _expiry.Remove(ip); } } - while (dead.Count > 0) - { - _expiry.Remove(dead.Dequeue()); - } } } diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs index ed65b5ec6..c44ceb33d 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs @@ -20,7 +20,6 @@ using System.IO; using System.Net; using System.Text; using System.Threading.Tasks; -using Server.Collections; using Server.Logging; using Server.Network.Bans; @@ -230,13 +229,15 @@ public static class LoginAllowlist var stamps = new long[_allowed.Count]; var count = 0; - using var expired = new PooledRefList(16); + var dropped = 0; foreach (var (address, stamp) in _allowed) { if (stamp < cutoff) { - expired.Add(address); + _allowed.Remove(address); + _strikes.Remove(address); + dropped++; continue; } @@ -245,19 +246,12 @@ public static class LoginAllowlist count++; } - for (var i = 0; i < expired.Count; i++) - { - _allowed.Remove(expired[i]); - _strikes.Remove(expired[i]); - } - PruneStaleStrikes(nowUnix); _dirty = false; var path = _path; var total = count; - var dropped = expired.Count; _ = Task.Run(() => Write(path, addresses, stamps, total, dropped)); } @@ -270,20 +264,13 @@ public static class LoginAllowlist return; } - using var stale = new PooledRefList(16); - foreach (var (address, strike) in _strikes) { if (nowUnix - strike.WindowStart > _strikeWindowSeconds) { - stale.Add(address); + _strikes.Remove(address); } } - - for (var i = 0; i < stale.Count; i++) - { - _strikes.Remove(stale[i]); - } } private static void Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs index 60fcc899f..e37431ddd 100644 --- a/Projects/UOContent/Skills/AntiMacroSystem.cs +++ b/Projects/UOContent/Skills/AntiMacroSystem.cs @@ -120,23 +120,17 @@ public static class AntiMacroSystem var now = Core.Now; - using var toRemove = PooledRefQueue.Create(); foreach (var (m, antiMacro) in _antiMacroTable) { if (antiMacro._lastExpiration <= now) { - toRemove.Enqueue(m); + _antiMacroTable.Remove(m); } else { antiMacro.CleanExpired(); } } - - while (toRemove.Count > 0) - { - _antiMacroTable.Remove(toRemove.Dequeue()); - } } [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] @@ -259,20 +253,13 @@ public static class AntiMacroSystem { var now = Core.Now; - using var toRemove = PooledRefQueue<(Skill, object)>.Create(); - foreach (var (key, countAndTimeStamp) in _antiMacroTracking) { if (countAndTimeStamp._count <= 0 || countAndTimeStamp._expiration <= now) { - toRemove.Enqueue(key); + _antiMacroTracking.Remove(key); } } - - while (toRemove.Count > 0) - { - _antiMacroTracking.Remove(toRemove.Dequeue()); - } } } diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs index b1b0a02fb..487eda693 100644 --- a/Projects/UOContent/Skills/DetectHidden.cs +++ b/Projects/UOContent/Skills/DetectHidden.cs @@ -39,20 +39,13 @@ public static class DetectHidden // Clean up old debounce entries to prevent memory bloat private static void CleanupDebounceCache(long now) { - using var entriesToRemove = PooledRefQueue<(Mobile, Mobile)>.Create(); - foreach (var entry in PassiveDetectDebounce) { if (now - entry.Value > DebounceExpiryMs) { - entriesToRemove.Enqueue(entry.Key); + PassiveDetectDebounce.Remove(entry.Key); } } - - while (entriesToRemove.Count > 0) - { - PassiveDetectDebounce.Remove(entriesToRemove.Dequeue()); - } } // For testing: clear the debounce cache to prevent cross-test contamination