Skip to content

fix: Pin CallbackDelegate with GCHandle to prevent GC collection on .… - #1363

Open
w7rus wants to merge 1 commit into
roflmuffin:mainfrom
w7rus:fix/gc-collected-delegate-crash
Open

fix: Pin CallbackDelegate with GCHandle to prevent GC collection on .…#1363
w7rus wants to merge 1 commit into
roflmuffin:mainfrom
w7rus:fix/gc-collected-delegate-crash

Conversation

@w7rus

@w7rus w7rus commented Jul 15, 2026

Copy link
Copy Markdown

Environment

  • OS: Fedora 42 (Linux)
  • CS2 Server: Dedicated, running via ./cs2 -dedicated -insecure 1.41.7.0 (build 2000875)
  • .NET Runtime (bundled): 10.0.3 (counterstrikesharp/dotnet/shared/Microsoft.NETCore.App/10.0.3/)
  • CounterStrikeSharp: counterstrikesharp-with-runtime-linux-1.0.371
  • MetaMod: mmsource-2.0.0-git1406-linux
  • Plugin affected: ExamplePlugin

Description

After the latest CounterStrikeSharp update, the CS2 dedicated server crashes with a garbage collected delegate error. The crash occurs when native code attempts to invoke a managed callback (OnMapEnd listener) whose delegate has already been collected by the GC. This did not happen prior to the update — the same plugin configuration was stable before.

Crash Log

[META] Failed to load plugin addons/counterstrikesharp/bin/linuxsteamrt64/counterstrikesharp:
/home/server/apps/cs2_server/app/game/csgo/addons/counterstrikesharp/bin/linuxsteamrt64/counterstrikesharp.so:
cannot enable executable stack as shared object requires: Invalid argument
A callback was made on a garbage collected delegate of type
'CounterStrikeSharp.API!CounterStrikeSharp.API.Core.FunctionReference+CallbackDelegate::Invoke'.
./cs2server_run.sh: строка 9: 456733 Аварийный останов (образ памяти сброшен на диск)
./cs2 -dedicated -insecure +map de_dust2 +sv_lan 1 +game_type 0 +game_mode 1

Analysis

A managed dump was captured from the live process using the bundled createdump tool before the crash occurred:

[createdump] Gathering state for process 479095 cs2
[createdump] Writing full dump to file /home/server/cs2_managed.dmp
[createdump] Written 2122055680 bytes (518080 pages) to core file
[createdump] Target process is alive
[createdump] Dump successfully written in 3222ms

The dump was analyzed with dotnet-dump analyze using setclrpath to point to the bundled runtime.

CallbackDelegate instances

> dumpheap -type CallbackDelegate
         Address               MT           Size
    7fcf4804d9a8     7fde7e24efe8             64
    7fcf481ebf08     7fde7e24efe8             64
    7fcf481f27a0     7fde7e24efe8             64
    7fcf481f2bb0     7fde7e24efe8             64
    7fcf481f2fb8     7fde7e24efe8             64
    7fcf481f33b0     7fde7e24efe8             64
    7fcf481f37c0     7fde7e24efe8             64
    7fcf481f3bc0     7fde7e24efe8             64
    7fcf481f3fc0     7fde7e24efe8             64
    7fcf481f43c0     7fde7e24efe8             64
    7fcf481f4bf8     7fde7e24efe8             64
    7fcf481fad30     7fde7e24efe8             64
    7fcf481fb0b0     7fde7e24efe8             64
    7fcf481fb680     7fde7e24efe8             64
    7fcf481fbb18     7fde7e24efe8             64
    7fcf481fbd88     7fde7e24efe8             64
    7fcf481fd858     7fde7e24efe8             64
    7fcf481fd9b8     7fde7e24efe8             64
    7fcf482a4ca8     7fde7e24efe8             64

The first delegate (7fcf4804d9a8) has 2660 unique GC roots — properly pinned. The remaining delegates have only 4 GC roots each, meaning they are weakly held and eligible for garbage collection while native code still holds pointers to them.

Tracing the weakly-rooted delegate to its source

Step 1 — dumpdelegate on a weakly-rooted CallbackDelegate:

> dumpdelegate 7fcf481ebf08
Target           Method           Name
00007fcf481ebe80 00007fde7e18b180 CounterStrikeSharp.API.Core.FunctionReference.<CreateWrappedCallback>b__18_0(CounterStrikeSharp.API.Core.fxScriptContext*)

Step 2 — dumpobj on the FunctionReference wrapper:

> dumpobj 7fcf481ebe80
Name:        CounterStrikeSharp.API.Core.FunctionReference
MethodTable: 00007fde7e18b1b8
Canonical MethodTable: 00007fde7e18b1b8
Tracked Type: false
Size:        48(0x30) bytes
File:        /home/server/apps/cs2_server/app/game/csgo/addons/counterstrikesharp/api/CounterStrikeSharp.API.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007fde7d1fa510  40009bc        8      System.Delegate  0 instance 00007fcf481ebdd0 _targetMethod
00007fde7e24efe8  40009bd       10 ...+CallbackDelegate  0 instance 00007fcf481ebf08 _nativeCallback
00007fde7e293fa0  40009be       18 ...kCompletionSource  0 instance 00007fcf481ebeb0 _taskCompletionSource
00007fde7e18aec8  40009bf       20         System.Int32  1 instance                1 <Lifetime>k__BackingField
00007fde7d1d74e8  40009c0       24         System.Int32  1 instance                1 <Identifier>k__BackingField
00007fde7e292a70  40009b8        8 ...StrikeSharp.API]]  0   static 00007fcf4804d220 IdToFunctionReferencesMap
00007fde7e291238  40009b9       10 ...StrikeSharp.API]]  0   static 00007fcf4804d588 TargetMethodToFunctionReferencesMap
00007fde7c1d47b0  40009ba       18        System.Object  0   static 00007fcf4804d908 ReferenceCounterLock
00007fde7d1d74e8  40009bb        8         System.Int32  1   static               20 _referenceCounter

Step 3 — dumpdelegate on the underlying _targetMethod:

> dumpdelegate 7fcf481ebdd0
Target           Method           Name
00007fcf481eb9b0 00007fde82cbd850 CounterStrikeSharp.API.Core.BasePlugin+<>c__DisplayClass56_0`1[[System.__Canon, System.Private.CoreLib]].<RegisterListener>b__2(CounterStrikeSharp.API.Core.ScriptContext)

Step 4 — dumpobj on the display class to find the listener type:

> dumpobj 7fcf481eb9b0
Name:        CounterStrikeSharp.API.Core.BasePlugin+<>c__DisplayClass56_0`1[[CounterStrikeSharp.API.Core.Listeners+OnMapEnd, CounterStrikeSharp.API]]
MethodTable: 00007fde82cbe170
Canonical MethodTable: 00007fde82cbd898
Tracked Type: false
Size:        56(0x38) bytes
File:        /home/server/apps/cs2_server/app/game/csgo/addons/counterstrikesharp/api/CounterStrikeSharp.API.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007fde7d4059c8  4001ecd        8        System.Type[]  0 instance 00007fcf48013648 parameterTypes
00007fde7d4059c8  4001ece       10        System.Type[]  0 instance 00007fcf48013648 castedParameterTypes
00007fde7c1d4838  4001ecf       18       System.__Canon  0 instance 00007fcf481eb970 handler
00007fde7e18a468  4001ed0       20 ...I.Core.BasePlugin  0 instance 00007fcf481eb670 <>4__this
00007fde7d1fdd58  4001ed1       28        System.String  0 instance 00007fcf481eb9e8 listenerName

Step 5 — dumpobj on <>4__this reveals the plugin:

> dumpobj 7fcf481eb670
Name:        ExamplePlugin.ExamplePlugin
MethodTable: 00007fde81ba6af0
Canonical MethodTable: 00007fde81ba6af0
Tracked Type: false
Size:        200(0xc8) bytes
File:        ExamplePlugin.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007fde7d1d30e0  4000990       78       System.Boolean  1 instance                0 _disposed
00007fde7d1fdd58  4000991        8        System.String  0 instance 0000000000000000 <ModuleAuthor>k__BackingField
00007fde7d1fdd58  4000992       10        System.String  0 instance 0000000000000000 <ModuleDescription>k__BackingField
00007fde7d1fdd58  4000993       18        System.String  0 instance 00007fcf481216b8 <ModulePath>k__BackingField
00007fde7db46fb8  4000994       20 ...s.Logging.ILogger  0 instance 00007fcf481fa350 <Logger>k__BackingField
00007fde7d491c28  4000995       28 ...s.ICommandManager  0 instance 00007fcf481edc10 <CommandManager>k__BackingField
00007fde7db8a750  4000996       30 ....IStringLocalizer  0 instance 00007fcf481f9b20 <Localizer>k__BackingField
00007fde7e225b78  4000997       38 ...SelfPluginControl  0 instance 00007fcf481218c8 <SelfControl>k__BackingField
00007fde82cbc2a0  4000998       40 ...StrikeSharp.API]]  0 instance 00007fcf481eb788 Handlers
00007fde82cbc2a0  4000999       48 ...StrikeSharp.API]]  0 instance 00007fcf481eb7d8 CommandListeners
00007fde82cbc2a0  400099a       50 ...StrikeSharp.API]]  0 instance 00007fcf481eb828 Listeners
00007fde82cbc2a0  400099b       58 ...StrikeSharp.API]]  0 instance 00007fcf481eb878 EntityOutputHooks
00007fde82cbcaa0  400099c       60 ...StrikeSharp.API]]  0 instance 00007fcf481eb8c8 EntitySingleOutputHooks
00007fde7e350018  400099d       68 ...StrikeSharp.API]]  0 instance 00007fcf481eb918 CommandDefinitions
00007fde82cbd188  400099e       70 ...StrikeSharp.API]]  0 instance 00007fcf481eb938 Timers
00007fde8292c4e0  400000a       80 ...ng.Abstractions]]  0 instance 00007fcf481e9758 _hostedServices
00007fde82919d10  400000b       88 ...oseLeaguePlugin]]  0 instance 00007fcf48188f08 _logger
00007fde81ba8340  400000c       90 ...es.IPluginService  0 instance 00007fcf481e30b0 _pluginService
00007fde81ba7610  400000d       98 ...ManagementService  0 instance 00007fcf481e2b80 _pauseManagementService
00007fde81ba7d28  400000e       a0 ...ManagementService  0 instance 00007fcf481e2c68 _playerTimeoutManagementService
00007fde81ba79d0  400000f       a8 ...ManagementService  0 instance 00007fcf481e32b0 _playerStatsManagementService
00007fde82cbc2a0  4000010       b0 ...StrikeSharp.API]]  0 instance 00007fcf481eb738 _serverStatusSendCallbackSubscribers
00007fde7d7abfb8  4000011       b8 ...lationTokenSource  0 instance 00007fcf481faa30 _cancellationTokenSource

Root Cause

FunctionReference never pins its CallbackDelegate (_nativeCallback) with a GCHandle. It relies solely on managed references through static ConcurrentDictionary maps to keep the delegate alive. When Marshal.GetFunctionPointerForDelegate is called, the native side receives a raw function pointer, but the GC has no knowledge of this unmanaged reference.

On .NET 8 (Linux), Marshal.GetFunctionPointerForDelegate internally maintained an implicit GCHandle on the delegate, which kept it alive regardless of managed reference strength. On .NET 10 (Linux), this internal pinning was removed — the runtime now strictly follows the documented contract: "You must manually keep the delegate from being collected by the garbage collector from managed code."

As a result, between callback registration and invocation, the GC collects the CallbackDelegate even though the FunctionReference holding it is still in the static maps. The managed reference chain (ConcurrentDictionaryFunctionReference_nativeCallback) is not sufficient under .NET 10's more aggressive GC on Linux.

This was not happening before the .NET 10 upgrade (492727e8) because the runtime itself was keeping delegates alive. The issue is Linux-only because the native-to-managed thunk mechanism and GC behavior differ between platforms.

Secondary issue: BasePlugin.Dispose() corrupts cleanup

BasePlugin.Dispose() iterates Handlers.Values, Listeners.Values, etc. in foreach loops. Each subscriber.Dispose() call modifies the underlying dictionary (via Remove), causing InvalidOperationException on the next iteration. This means only the first subscriber in each dictionary gets properly cleaned up — remaining handlers/listeners are never unhooked from native and their FunctionReference entries are never removed. While this doesn't directly cause the GC crash (it actually prevents FunctionReference.Remove from running), it leaves orphaned native hooks and leaked FunctionReference entries.

Fix

1. Pin CallbackDelegate with GCHandle in FunctionReference

Added GCHandle.Alloc(_nativeCallback) in the constructor to explicitly prevent GC collection of the delegate while native code holds a pointer to it. The handle is freed in Remove() when the callback is intentionally unregistered.

// FunctionReference.cs
private readonly GCHandle _nativeCallbackHandle;

private FunctionReference(Delegate method, FunctionLifetime lifetime)
{
    Lifetime = lifetime;
    _targetMethod = method;
    _nativeCallback = CreateWrappedCallback();
    _nativeCallbackHandle = GCHandle.Alloc(_nativeCallback);
}

public static void Remove(int reference)
{
    if (IdToFunctionReferencesMap.TryGetValue(reference, out var functionReference))
    {
        // ... existing removal logic ...

        if (functionReference._nativeCallbackHandle.IsAllocated)
        {
            functionReference._nativeCallbackHandle.Free();
        }
    }
}

2. Fix BasePlugin.Dispose() dictionary mutation during iteration

Snapshot each dictionary's values with .ToList() before iterating, preventing InvalidOperationException and ensuring all subscribers are properly cleaned up.

// BasePlugin.cs — Dispose(bool disposing)
foreach (var subscriber in Handlers.Values.ToList()) { subscriber.Dispose(); }
foreach (var subscriber in CommandListeners.Values.ToList()) { subscriber.Dispose(); }
foreach (var subscriber in Listeners.Values.ToList()) { subscriber.Dispose(); }
foreach (var subscriber in EntityOutputHooks.Values.ToList()) { subscriber.Dispose(); }
foreach (var definition in CommandDefinitions.ToList()) { CommandManager.RemoveCommand(definition); }
foreach (var timer in Timers.ToList()) { timer.Kill(); }

Steps to Reproduce

  1. Install CounterStrikeSharp (latest update with .NET 10) on a Linux CS2 dedicated server
  2. Install any plugin that registers event handlers or listeners (e.g., player_connect_full, OnMapEnd)
  3. Start the server and trigger the registered callback (player connect, map change, etc.)
  4. Server crashes with A callback was made on a garbage collected delegate of type 'CounterStrikeSharp.API!CounterStrikeSharp.API.Core.FunctionReference+CallbackDelegate::Invoke'

Additional Notes

  • The execstack issue (cannot enable executable stack as shared object requires: Invalid argument) also appeared on Fedora 42 and was resolved separately with execstack -c on the shared object.
  • The first CallbackDelegate in the heap (7fcf4804d9a8) has 2660 roots and is stable — the issue is specific to listener-registered delegates, not all delegates.
  • The fix has been verified on the same Fedora 42 environment that originally reproduced the crash.

…NET 10 Linux

On .NET 10 Linux, Marshal.GetFunctionPointerForDelegate no longer
internally pins the delegate, causing the GC to collect CallbackDelegate
instances while native code still holds function pointers to them. This
results in crashes with "callback was made on a garbage collected delegate".

Add GCHandle.Alloc in FunctionReference constructor to explicitly prevent
collection. Free the handle in Remove() during intentional cleanup.

Also fix BasePlugin.Dispose() modifying dictionaries during foreach
iteration by snapshotting values with .ToList() first.
@w7rus
w7rus requested a review from roflmuffin as a code owner July 15, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant