Fix "Collection was modified; enumeration operation may not execute" in Oxide plugins
An InvalidOperationException thrown when a Dictionary, List or HashSet is changed while a foreach loop is still walking over it. Collect the keys or items into a new list first, then remove them after the loop.
Failed to run a 1.00 timer in 'CooldownManager v1.0.0' (InvalidOperationException: Collection was modified; enumeration operation may not execute.) at System.Collections.Generic.Dictionary`2+Enumerator[TKey,TValue].MoveNext () [0x00013] in <b0e1ad7573a24fd5a9f2af9595e677e7>:0 at Oxide.Plugins.CooldownManager.<OnServerInitialized>b__5_0 () [0x0002a] in <8f3c1a2b4d5e4f6a9b0c1d2e3f4a5b6c>:0
Why it happens
C# enumerators throw this exception when the underlying collection's version counter changes between two MoveNext calls. In Oxide plugins the usual pattern is a foreach over a dictionary of cooldowns, timers or players where the loop body calls Remove, Add or Clear on the same dictionary — for example foreach (var kvp in cooldowns) if (kvp.Value <= now) cooldowns.Remove(kvp.Key);. The same happens with a List when Remove or Add runs inside the loop, or when a nested call (a hook that fires synchronously, a timer callback, another plugin method) mutates the collection while you iterate it. Console timers and OnServerInitialized are common places because they iterate whole collections and clean up expired entries. Oxide only logs "Failed to run a <seconds> timer" and destroys that timer; the plugin stays loaded, but that cleanup never runs again until the plugin is reloaded, so expired entries quietly pile up. The same loop inside a hook throws on every call instead.
How to fix it
- Find the loop the stack trace points at (the method named in the frame below
<OnServerInitialized>here). - Do not remove or add anything inside that
foreach. - Collect the keys you want to drop into a
List<TKey>or a fresh array, then loop over that list and callRemove. Alternatively iteratenew List<TKey>(dictionary.Keys)ordictionary.Keys.ToArray()(needsusing System.Linq;). - The same rule applies to lists: build a removal list, or iterate backwards with an index
for (int i = list.Count - 1; i >= 0; i--). - Save the file to trigger Oxide's automatic reload and run
oxide.reload CooldownManagerto be sure. The console should printLoaded plugin CooldownManager v1.0.0 by ...and no timer exception after the next cycle. Watchoxide/logsif you need the full trace again.
Before and after
using System;
using System.Collections.Generic;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("CooldownManager", "RustPluginGenerator", "1.0.0")]
[Description("Clears expired cooldowns every second.")]
public class CooldownManager : RustPlugin
{
private readonly Dictionary<ulong, DateTime> cooldowns = new Dictionary<ulong, DateTime>();
private void OnServerInitialized()
{
// seed a few cooldowns so there is something to clean up
cooldowns[76561198000000001] = DateTime.UtcNow.AddSeconds(-5);
cooldowns[76561198000000002] = DateTime.UtcNow.AddSeconds(30);
timer.Repeat(1f, 0, Cleanup);
}
private void Cleanup()
{
DateTime now = DateTime.UtcNow;
// BUG: removing from the dictionary while foreach is walking it
foreach (KeyValuePair<ulong, DateTime> entry in cooldowns)
{
if (entry.Value <= now)
{
cooldowns.Remove(entry.Key);
Puts("Cooldown expired for " + entry.Key);
}
}
}
}
}using System;
using System.Collections.Generic;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("CooldownManager", "RustPluginGenerator", "1.0.0")]
[Description("Clears expired cooldowns every second.")]
public class CooldownManager : RustPlugin
{
private readonly Dictionary<ulong, DateTime> cooldowns = new Dictionary<ulong, DateTime>();
private Timer cleanupTimer;
private void OnServerInitialized()
{
cooldowns[76561198000000001] = DateTime.UtcNow.AddSeconds(-5);
cooldowns[76561198000000002] = DateTime.UtcNow.AddSeconds(30);
cleanupTimer = timer.Repeat(1f, 0, Cleanup);
}
private void Cleanup()
{
if (cooldowns.Count == 0)
{
return;
}
DateTime now = DateTime.UtcNow;
// collect the expired keys first, then remove them
List<ulong> expired = new List<ulong>();
foreach (KeyValuePair<ulong, DateTime> entry in cooldowns)
{
if (entry.Value <= now)
{
expired.Add(entry.Key);
}
}
if (expired.Count == 0)
{
return;
}
foreach (ulong userId in expired)
{
cooldowns.Remove(userId);
Puts("Cooldown expired for " + userId);
}
}
private void Unload()
{
if (cleanupTimer != null)
{
cleanupTimer.Destroy();
cleanupTimer = null;
}
cooldowns.Clear();
}
}
}FAQ
Does this exception unload my plugin?
No. Oxide catches it, prints Failed to run a <seconds> timer in '<Name> v<Version>' and destroys that single timer. The plugin remains loaded, but anything the timer was supposed to do stops happening, so check that the timer is recreated after you fix the loop.
Why does iterating `Dictionary.Keys` also throw?
Dictionary<K,V>.Keys is a live view over the same dictionary, not a copy. Changing the dictionary while iterating that view changes the version counter and the enumerator throws. Wrap it in new List<TKey>(dict.Keys) to get an independent snapshot.
Can a hook cause this while a foreach is running?
Yes. If your loop body calls something that synchronously invokes another method which adds or removes entries in the same collection, the enumerator still sees a modified collection. Copying the keys before the loop avoids that too.
Is this different on Carbon?
No. Carbon loads the same plugin file and runs it on the same .NET runtime, so the exception message and the fix are identical. Only the log folder and the extra c.* console aliases differ.
Different error?
Paste the full console output and get the cause and a fix in plain English.
Open the error explainerSkip the debugging
Generated plugins are compiled before you get them, and compiler errors are fixed automatically.
Generate a plugin2 free plugins a month, no card.
Last updated Oct 26, 2026.
Get plugins that compile first time
Generate