Before You Report
Version
1.1.6
Description
In LabApi\Events\EventManager.cs, the methods InvokeEvent and InvokeEvent<TEventArgs> iterate over subscribers using foreach (Delegate sub in eventHandler.GetInvocationList()).
In C#, MulticastDelegate.GetInvocationList() allocates and returns a new array on the heap every single time it is called. For high-frequency events (e.g., movement state changes, custom updates, or combat events), this creates hundreds of arrays per second. This massive garbage generation forces the Garbage Collector to run frequently, causing micro-stutters and degrading server TPS over time.
To Reproduce
The following PoC command subscribes 5 empty delegates to a dummy event and invokes it 10,000 times (simulating a few seconds of high-frequency event spam on a populated server).
Proof of Concept Code
using System;
using CommandSystem;
using LabApi.Events;
using LabApi.Loader.Features.Plugins;
namespace BugRepro.GCTest
{
public class GCTestPlugin : Plugin
{
public override string Name => "GCTestPlugin";
public override string Description => "Tests GC allocations.";
public override string Author => "QA";
public override Version Version => new Version(1, 0, 0);
public override Version RequiredApiVersion => new Version(1, 0, 0);
public override void Enable() { }
public override void Disable() { }
}
[CommandHandler(typeof(RemoteAdminCommandHandler))]
public class GCTestCommand : ICommand
{
public string Command => "testgc";
public string[] Aliases => new string[0];
public string Description => "Measures GC allocation for LabAPI events.";
public static event LabEventHandler? DummyEvent;
public bool Execute(ArraySegment<string> arguments, ICommandSender sender, out string response)
{
DummyEvent += () => { };
DummyEvent += () => { };
DummyEvent += () => { };
DummyEvent += () => { };
DummyEvent += () => { };
GC.Collect();
GC.WaitForPendingFinalizers();
long memoryBefore = GC.GetTotalMemory(false);
for (int i = 0; i < 10000; i++)
{
DummyEvent.InvokeEvent();
}
long memoryAfter = GC.GetTotalMemory(false);
long allocated = memoryAfter - memoryBefore;
DummyEvent = null;
response = $"Invoked 10,000 times. Memory allocated: {allocated / 1024} KB.";
return true;
}
}
}
Result:
Running testgc allocates ~720 KB of memory for literally doing nothing.
Expected Behavior
Event invocation should be zero-allocation (or as close to it as possible) to maintain server performance.
Additional Information
Proposed Fix:
Avoid calling GetInvocationList() on every invoke.
- Option A: Implement a custom Event wrapper class that maintains a
List<Delegate> internally. When a plugin subscribes/unsubscribes, update the list. During invocation, iterate over the cached list (which doesn't allocate memory).
- Option B: If
try-catch isolation per subscriber is not strictly required, invoke the multicast delegate directly (eventHandler()), which is highly optimized by the CLR. If isolation is required, Option A is the standard approach for high-performance C# game servers.
Before You Report
Version
1.1.6
Description
In
LabApi\Events\EventManager.cs, the methodsInvokeEventandInvokeEvent<TEventArgs>iterate over subscribers usingforeach (Delegate sub in eventHandler.GetInvocationList()).In C#,
MulticastDelegate.GetInvocationList()allocates and returns a new array on the heap every single time it is called. For high-frequency events (e.g., movement state changes, custom updates, or combat events), this creates hundreds of arrays per second. This massive garbage generation forces the Garbage Collector to run frequently, causing micro-stutters and degrading server TPS over time.To Reproduce
The following PoC command subscribes 5 empty delegates to a dummy event and invokes it 10,000 times (simulating a few seconds of high-frequency event spam on a populated server).
Proof of Concept Code
Result:
Running
testgcallocates ~720 KB of memory for literally doing nothing.Expected Behavior
Event invocation should be zero-allocation (or as close to it as possible) to maintain server performance.
Additional Information
Proposed Fix:
Avoid calling
GetInvocationList()on every invoke.List<Delegate>internally. When a plugin subscribes/unsubscribes, update the list. During invocation, iterate over the cached list (which doesn't allocate memory).try-catchisolation per subscriber is not strictly required, invoke the multicast delegate directly (eventHandler()), which is highly optimized by the CLR. If isolation is required, Option A is the standard approach for high-performance C# game servers.