Fix dropped connections never saving the player - #1249
Open
HarleyGilpin wants to merge 1 commit into
Open
Conversation
Client.disconnect() set state to Disconnected, and Client.exit() only invoked the disconnecting hook while state was Connected. A client closing its socket makes the next write throw, the write error handler calls disconnect(), and by the time LoginServer's finally block reaches exit() the hook is skipped. AccountManager.logout never runs, so neither does saveQueue.save(player) or Players.remove(player) - that block is their only production call site. The orphaned Player stays in the world at the tile it held when the connection dropped, and AutoSave rewrites that tile over the account file every interval, undoing whatever later sessions saved. Audit logs on a dev server show 29176 CONNECTED against 424 DISCONNECTED events. disconnect() no longer advances state; exit() sets Disconnected once the hook has run. send() was already suppressed by the disconnected flag, so that assignment only ever served to disable exit(). Three related paths lost sessions the same way: Despawn.logout let a script cancel an involuntary disconnect. The only handler, TzhaarFightCave.logoutChoice, warns the player and returns false on first call, which cannot work for someone whose socket is gone and left them unsaved in the caves. The veto is now scoped to voluntary logout, so clicking Logout still warns then logs out on the second click. SaveQueue removed entries from pending by key after writing, discarding any snapshot that replaced them while the write was in flight. The job.isActive guard from GregHib#1215 widened that window from one tick to the whole write. clearPending now removes by identity so a superseded snapshot survives to the next tick. SaveQueue.direct() snapshotted Players and ignored pending, so a player who logged out cleanly was already out of Players and lost their save if the server stopped before the next tick wrote it. It now includes pending entries for accounts no longer online, and the shutdown hook awaits the in-flight write first so the two don't race the same file. Login also reaps a stale session for the account before loading, since LoginServer frees the username a tick before the save is queued. Each fix has a test that fails without it. AccountManagerTest drives the production teardown sequence through a real client and asserts the save is queued; on the old code it fails with "Dropped connection never queued a save, losing the session".
HarleyGilpin
force-pushed
the
fix/dropped-connection-save-loss
branch
from
September 1, 2026 22:46
e2bd023 to
360a04f
Compare
This was referenced Sep 1, 2026
A single failed save diverts every pending account into SafeStorage, which cannot be read back
#1250
Open
GregHib
reviewed
Sep 2, 2026
| logger.warn { "Error setting up account" } | ||
| client.disconnect(Response.WORLD_FULL) | ||
| return@withContext | ||
| } |
Owner
There was a problem hiding this comment.
So the reason setup was before was so that a large account would be loaded off of the game thread, which is what created the stale account sure, but ideally we can keep that functionality and load first and connected once ready
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Players log back in at a position from hours earlier.
Cause
Client.disconnect()setstatetoDisconnected, andClient.exit()only invoked thedisconnectinghook whilestatewasConnected:A client closing its socket makes the next write throw. The write error handler (
Client.kt:19-24) callsdisconnect(), so by the time the read loop unwinds intoLoginServer.kt:144-146'sfinally { client.exit(); client.disconnect() },exit()is a no-op.AccountManager.logoutnever runs, and neither does anything inside it:connectionQueue.disconnect { World.queue("logout_${player.accountName}", 1) { Players.remove(player) // only production call site } Despawn.player(player) saveQueue.save(player) // only production call site AuditLog.event(player, "disconnected", player.tile) }Both
saveQueue.saveandPlayers.removeappear nowhere else outside tests. So the session isn't saved, and thePlayerstays in the world frozen at the tile it held when the connection went.AutoSaveiteratesPlayerswith nologged_outfilter, so that stale tile gets written over the account file every interval, undoing whatever a later session saved. Which write wins depends on list order, hence the inconsistent reproduction.Fix
disconnect()no longer touchesstate;exit()setsDisconnectedafter the hook runs.send()was already gated on thedisconnectedflag (Client.kt:75), so that assignment only ever served to disableexit().Three other paths lost sessions the same way.
Despawn.logoutlet a script cancel an involuntary disconnect. Its only handler,TzhaarFightCave.logoutChoice, messages the player and returnsfalseon first call, which can't work for someone whose socket is already gone and left them unsaved in the caves. The veto is now scoped to voluntary logout. Clicking Logout still warns and logs out on the second click, covered by the existing tests.SaveQueueremoved entries frompendingby key after writing, discarding any snapshot that replaced them mid-write. Thejob.isActiveguard from #1215 widened that window from one tick to the whole write, so it starts biting as soon as logout begins queueing saves again.clearPendingremoves by identity instead, letting a superseded snapshot survive to the next tick.SaveQueue.direct()is the shutdown save and it snapshottedPlayerswhile ignoringpending. A player who logged out cleanly is already out ofPlayersand waiting inpendingfor the next tick, so stopping the server in that window dropped their save. It now includes pending entries for accounts that are no longer online, andAutoSave'sworldDespawnawaits the in-flight write before taking the shutdown snapshot, so the two don't race the same file. This one affects clean logouts, not just dropped connections.Login also reaps a stale session for the account before loading, since
LoginServerfrees the username a tick before the save is queued. MovingsetupinsidewithContext(gameContext)fixes a second thing:Players.index()was being called off the game thread, where two concurrent logins can be handed the same index andPlayers.addsilently returnsfalsefor the loser.Tests
Each change has a test that fails without it.
AccountManagerTestruns the production teardown sequence through a realDummyClient, whosedisconnect(),exit()and state machine are the real implementations:On the old code that fails with
expected: <true> but was: <false>.Clientstate machineDropped connection still saves the session,Exit still logs out after a write error disconnected the clientConnection loss mid wave still logs the player outSaveQueueidentity removalSave queued during a write isn't droppedShutdown save includes accounts pending from a logoutCan't login while an earlier session is still in the worldFull suite green.
Left alone
Found while tracing this, none of it needed here:
SaveQueue's exception handler drains all ofpendingtoSafeStorage, including accounts it never tried to write.SafeStorage.load()returns null andexists()returns false, so those accounts are gone from the real storage path rather than merely dumped.AutoSave.kt:32callsWorld.contains("auto_save"), which reads a world variable rather thanWorld.containsQueue. The condition is always false, so::reload settingsre-queues and pushes the deadline out, and theclearQueuebranch is unreachable.Config.fileWritertruncates in place with no temp file or fsync, whilePlayerSave.copy()passes the livefriendsmap by reference when every other field is copied. A concurrent modification throws on the IO thread after the file is already truncated.