How you can help
- If you are a Roblox employee, please let me know if there is a guarantee on
PlayerRemoving
running beforeBindToClose
upon server shutdown and when the last player leaves. - Please let me know if there is a flaw in my logic, a better way to achieve my goal, or any suggestions for optimization.
Solution requirements
- I want to run a single callback whenever a player leaves exactly once. The player leaving can either be by choice or caused by a shutdown, whether they are the last to leave or not. My use case is saving data.
- I do not want to place restrictions on this callback, such as whether it yields, whether it uses the task scheduler, how many times it yields, etc.
- I also do not want the author of the callback to worry about how the game executes it, they should just be able to write an arbitrary function like they would anywhere else.
Resources and previous discussion
There are quite a few previous threads talking about this, including:
- The Roblox Documentation for
DataModel:BindToClose
, where they give a code sample which saves data for each player. - The Roblox documentation for
Players.PlayerRemoving
, which details the callback but no information about how it behaves during server shutdown. -
A thread by @sjr04 where they lay out how use
BindToClose
to save player data. -
This thread and this thread, which both discuss the order in which
PlayerRemoving
andBindToClose
fire.
What are the problems I’m trying to work around?
- There is no canonical information about whether
PlayerRemoving
andBindToClose
are guaranteed to fire and in what order. - Existing solutions in the Roblox documentation and DevForum do not meet the requirements I outlined above.
- The example code in the Roblox
BindToClose
documentation:- Does not take into account
PlayerRemoving
- Doesn’t allow multiple yields
- Doesn’t account for usages of
task.wait()
or othertask
scheduler logic. This is because if youtask.wait
, it yields the thread, and if you have acoroutine.resume()
call, you can resume a coroutine before it waits for the appropriate time interval. - Is somewhat complex as the child coroutines need to resume the parent.
- Does not take into account
-
The solution from @sjr04 does not allow for multiple yields or
task.wait()
calls for similar reasons as the ones above.
- The example code in the Roblox
Proposed solution
This solution:
- Assumes that
BindToClose
always fires upon server shutdown (may not be true during crashes, so we should periodically autosave, but that’s another issue.) - Does not assume whether or not
PlayerRemoving
fires, or if it does fire, assume it runs beforeBindToClose
.- In Studio, I have observed
PlayerRemoving
consistently fire beforeBindToClose
, but I don’t want to assume that will always be the case unless there is a guarantee.
- In Studio, I have observed
- Does not place any restrictions on the content of the callback, such as whether it yields, whether it uses
task.wait
, or anything else. - Maintains these properties at the cost of increased latency - it will check at an arbitrary interval whether all of the threads are done or not. The engineer in me kind of cringes as this seems like a hack, but I’m not sure it’s possible to do otherwise. If there is a better way to do this, let me know!
-- This is how often we poll to see whether the callbacks are done
local COROUTINE_CHECK_INTERVAL_SEC = 0.1
-- This map will store the callback thread for each player.
local callbackThreads: { [Player]: thread } = {}
-- This function has multiple yields, print statements, and `task.wait`s so that
-- we can observe it fulfills all of the desired properties.
local playerLeavingCallback = function(_: Player)
print("Player leaving waiting for one second")
task.wait(1)
print("Waiting again...")
task.wait(1)
print("Player leaving callback finished!")
end
local spawnPlayerLeavingCallbackIfNotExists = function(player: Player)
-- If a thread already exists for a player, we do not want to create one.
if not callbackThreads[player] then
-- Immediately spawn a thread which will run the callback
callbackThreads[player] = task.spawn(function()
playerLeavingCallback(player)
end)
end
end
-- This is the normal case when the server isn't shutting down.
game.Players.PlayerRemoving:Connect(spawnPlayerLeavingCallbackIfNotExists)
game:BindToClose(function()
-- For each player remaining on the server, we spawn a callback if one doesn't exist.
for _, player in game.Players:GetPlayers() do
spawnPlayerLeavingCallbackIfNotExists(player)
end
local threadsStillRunning: boolean
repeat
-- We want to determine if any callback hasn't finished running.
local threadsStillRunning = false
for _, thread in callbackThreads do
if coroutine.status(thread) ~= "dead" then
threadsStillRunning = true
break
end
end
-- If threads are still running, yield the thread for some time
-- to wait for the callbacks to finish.
if threadsStillRunning then
task.wait(COROUTINE_CHECK_INTERVAL_SEC)
end
until not threadsStillRunning
end)