Skip to content

Conversation

@ratijas
Copy link

@ratijas ratijas commented Aug 8, 2025

CSync leaves stale change handlers after quitting the hosted game.

When the host quits the game back to the main menu, ConfigSyncBehaviour despawns and gets destroyed by Unity (not by .NET GC), but any delegates connected to events in OnNetworkSpawn method remain and go stale.

So next time a player hosts a lobby without shutting down and re-launching the game completely, when any of subscribed config files or config entries change, stale delegates execute and try to assign to a dead NetworkList _deltas[index]. Of course it results in an exception being logged to the console by BepInEx (which wraps event handler invocation in a try-catch).

To fix this, store subscribed delegates, and properly unsubscribe them during despawn.

Note that NetworkList and _syncEnabled variable can not be cleared/reset due to Unity issue which only got resolved in the most recent version.

References:

Fixes #13

CSync leaves stale change handlers after quitting the hosted game.

When the host quits the game back to the main menu, ConfigSyncBehaviour
despawns and gets destroyed by Unity (not by .NET GC), but any delegates
connected to events in OnNetworkSpawn method remain and go stale.

So next time a player hosts a lobby without shutting down and
re-launching the game completely, when any of subscribed config files
or config entries change, stale delegates execute and try to assign to
a dead NetworkList _deltas[index]. Of course it results in an exception
being logged to the console by BepInEx (which wraps event handler
invocation in a try-catch).

To fix this, store subscribed delegates, and properly unsubscribe them
during despawn.

Note that NetworkList and _syncEnabled variable can not be cleared/reset
due to Unity issue which only got resolved in the most recent version.

References:
- https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/using-delegates
- Unity-Technologies/com.unity.netcode.gameobjects#3502

Fixes lc-sigurd#13
@ratijas
Copy link
Author

ratijas commented Aug 8, 2025

I could combine two lists of delegates into one with a huge signature or an internal struct, but it doesn't matter too much.

@ratijas
Copy link
Author

ratijas commented Aug 12, 2025

Polite ping?

throw new InvalidOperationException("Entry container has not been assigned.");
}

private void Awake()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, do not do this. Revert.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_deltas is already initialized in the field declaration, so its reassignment shouldn't be needed (moreover, it is marked as readonly now).

Copy link

@Lordfirespeed Lordfirespeed Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the time I wrote the code, I knew field declaration initialisers existed and I intentionally didn't use one (hence null!)
I had a reason for doing that, I don't want to go digging for it again. This change introduces a bug risk which wasn't there before, accepting it would be a liability.

_syncEnabled.Value = true;

foreach (var syncedEntryBase in EntryContainer.Values)
foreach (var (syncedEntryBase, index) in EntryContainer.Values.Select((e, i) => (e, i)))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally did not do this. Revert or use an index-based for-loop

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind if I ask, for what reason? Is it not performant enough for a once-in-a-lifetime execution? Or not readable? Or something else?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cannot apply indexing with [] to an expression of type 'ICollection<SyncedEntryBase>'[CS0021](https://msdn.microsoft.com/query/roslyn.query?appId=roslyn&k=k(CS0021))

Dictionary's Values is not an array, so straightforward index-based for loop is not possible. No matter how you write it, it would suck either way. For example,

            var index = 0;
            foreach (var syncedEntryBase in EntryContainer.Values)
            {
                // do stuff
                // 20 lines of code later (possibly with break and continue), don't forget to increment
                index++;
            }

Copy link

@Lordfirespeed Lordfirespeed Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option 1: Array.from (less preferable)
Option 2: use IDictionary.Count to determine bounds of index iteration
Option 3: leave it as it is

My reasonining wasn't really about performance, it's more about readability. The way I originally wrote it was more readable. .Select() to get an index is magic BS that you have to be familiar with C# to know.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, Select() isn't the sweetest part of C#, but python-style enumerate() will only come in the next C#/.NET release :(

anyways, as you might have seen, I already changed it to index++ at the end of the loop. It gets the job done.

Copy link

@Lordfirespeed Lordfirespeed Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I prefer using currentIndex = blah.size at the top of the loop, since that will keep all the loop logic together 😅

}
else if (IsClient)
{
DisableOverrides();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been copied from OnDestroy, I don't like the duplication. Please can you explain why this is necessary?
If it's necessary, please can you test that this definitely gets run by all clients - then we can remove the OnDestroy override to remove the duplication

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't even remember for sure. But it perfectly mirrors whatever setup is being performed in OnNetworkSpawn(), so there's that. Probably gonna re-test it later.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mainly don't like the possibility that the clean-up is run twice.

@ratijas ratijas force-pushed the work/ratijas/delegates branch from a4f9c8d to 1c7cb9b Compare September 16, 2025 16:10
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.

CSync leaves stale change handlers after quitting the hosted game

2 participants