Skip to content

Fix dropped connections never saving the player - #1249

Open
HarleyGilpin wants to merge 1 commit into
GregHib:mainfrom
HarleyGilpin:fix/dropped-connection-save-loss
Open

Fix dropped connections never saving the player#1249
HarleyGilpin wants to merge 1 commit into
GregHib:mainfrom
HarleyGilpin:fix/dropped-connection-save-loss

Conversation

@HarleyGilpin

@HarleyGilpin HarleyGilpin commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Players log back in at a position from hours earlier.

Cause

Client.disconnect() set state to Disconnected, and Client.exit() only invoked the disconnecting hook while state was Connected:

suspend fun exit() {
    if (state == ClientState.Connected) {
        state = ClientState.Disconnecting
        disconnecting?.invoke()
    }
}

A client closing its socket makes the next write throw. The write error handler (Client.kt:19-24) calls disconnect(), so by the time the read loop unwinds into LoginServer.kt:144-146's finally { client.exit(); client.disconnect() }, exit() is a no-op. AccountManager.logout never 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.save and Players.remove appear nowhere else outside tests. So the session isn't saved, and the Player stays in the world frozen at the tile it held when the connection went. AutoSave iterates Players with no logged_out filter, 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 touches state; exit() sets Disconnected after the hook runs. send() was already gated on the disconnected flag (Client.kt:75), so that assignment only ever served to disable exit().

Three other paths lost sessions the same way.

Despawn.logout let a script cancel an involuntary disconnect. Its only handler, TzhaarFightCave.logoutChoice, messages the player and returns false on 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.

SaveQueue removed entries from pending by key after writing, discarding any snapshot that replaced them mid-write. The job.isActive guard from #1215 widened that window from one tick to the whole write, so it starts biting as soon as logout begins queueing saves again. clearPending removes by identity instead, letting a superseded snapshot survive to the next tick.

SaveQueue.direct() is the shutdown save and it snapshotted Players while ignoring pending. A player who logged out cleanly is already out of Players and waiting in pending for 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, and AutoSave's worldDespawn awaits 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 LoginServer frees the username a tick before the save is queued. Moving setup inside withContext(gameContext) fixes a second thing: Players.index() was being called off the game thread, where two concurrent logins can be handed the same index and Players.add silently returns false for the loser.

Tests

Each change has a test that fails without it. AccountManagerTest runs the production teardown sequence through a real DummyClient, whose disconnect(), exit() and state machine are the real implementations:

client.disconnect()   // write error handler
client.exit()         // LoginServer's finally
connectionQueue.run()
World.run()

assertTrue(saveQueue.saving("name"), "Dropped connection never queued a save, losing the session")

On the old code that fails with expected: <true> but was: <false>.

Change Test
Client state machine Dropped connection still saves the session, Exit still logs out after a write error disconnected the client
Logout veto scope Connection loss mid wave still logs the player out
SaveQueue identity removal Save queued during a write isn't dropped
Shutdown includes pending Shutdown save includes accounts pending from a logout
Stale session reaping Can't login while an earlier session is still in the world

Full suite green.

Left alone

Found while tracing this, none of it needed here:

  • SaveQueue's exception handler drains all of pending to SafeStorage, including accounts it never tried to write. SafeStorage.load() returns null and exists() returns false, so those accounts are gone from the real storage path rather than merely dumped.
  • AutoSave.kt:32 calls World.contains("auto_save"), which reads a world variable rather than World.containsQueue. The condition is always false, so ::reload settings re-queues and pushes the deadline out, and the clearQueue branch is unreachable.
  • Config.fileWriter truncates in place with no temp file or fsync, while PlayerSave.copy() passes the live friends map by reference when every other field is copied. A concurrent modification throws on the IO thread after the file is already truncated.

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".
logger.warn { "Error setting up account" }
client.disconnect(Response.WORLD_FULL)
return@withContext
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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

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.

2 participants