In my game I track players like this
PlayerAdded → Get data, place client in a table
PlayerRemoving → remove client from table
Now there’s a race condition in the retrieval of data since datastore calls are yielding. This means that if the player leaves during the process of getting data, the PlayerRemoving event would fire before the PlayerAdded callback had finished, and before the client was added to the table.
Normally this isn’t a problem, but because of my datastore implementation with session locking, I need to account for this edge case and close the datastore session for the client if they leave in the process of retrieving data.
I do this by checking to see if the player still exists inside of Players after retrieving data:
local data = GetData(UserId)
if not Players:GetPlayerByUserId(UserId) then --player left while getting data
--close session
end
However, for whatever reason, EVEN ONCE the player leaves, it still remains inside of Players for some interval of time, maybe less than a frame. This means that the detection line is saying the player is still in the game, and the function goes through, adding the client to the table at the end. However because the player isn’t actually in the game anymore the PlayerRemoving event had already fired before the PlayerAdded callback had finished, so there is a stale client inside of the table, and the datastore session for this player is frozen, preventing them from joining again.
Immediately after retrieving the data and doing the player existence check, I manually spawn in the player’s character and set the Player.Character property to it. (Everything after the retrieval of data is NON YIELDING so the race condition can only be coming from getting the data).
The error report logged:
Character cannot be changed as Player (playername) is being removed.
This means that
- Player Removing had already fired
- The server recognizes that the player had left and therefore locked the Character property
- The player still exists inside of the Players service and can be retrieved by GetPlayerByUserId
If the player existence check had detected that the player was no longer in the game, then it would trip a return nil statement and the function wouldn’t attempt to spawn the character
I suspect the root cause of this change in behavior is deferred events since this wasn’t always the case. I’m guessing that the removal of the player from the Players service registry is scheduled to occur at deferred point from Player Removing, and that this deferred point is MULTIPLE resumption points after Player Removing, such that it ends up after the Network Step (which is when threads calling async network functions like GetAsync, UpdateAsync, etc are resumed)
info from testing and microprofiler
-The player removing event fires at the earliest point in the frame, before input resumption point, prerender, and replication receive(network step) → for some reason schedules the removal of a player from Players to occur 3 or more resumption points later, probably due to a chain of deferrals
(Player remove event ->defer to pre render, run some callback → defer another thread, run code → defer again, this callback finally removes the player reference from Players)
Because threads yielding to web calls like GetAsync are resumed at the replication receive (I don’t have a picture for this but you can recreate a simple test and open microprofiler to see this behavior), the code in my function is doing the Players:GetPlayerByUserId check at this point, and seeing that the player still exists, BEFORE the very, very deferred backend callback from Player Removing manages to clean up the reference in Players service
Expected behavior
When PlayerRemoving fires, the player object for that specific player should no longer be retrievable from Players:GetPlayerByUserId