From 7aae5e523092ee31d9ba366c253d43546ff76b4d Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Mon, 20 Jul 2026 19:32:04 -0400 Subject: [PATCH 1/3] Locate a logging-out player across all zones, not just the recorded one A PersistenceMonitor removes a player's world entity via PerformLogout, which keyed the lookup solely off `inZone` -- the zone last reported through an Update heartbeat. On an abrupt disconnect (a crash during or after a zone transfer, or right after a respawn before an Update landed) `inZone` is stale: the live avatar or corpse actually resides in a different zone. The name lookups then find nothing, execution falls through to the "user stalled / caught between zone transfer" no-op, and the entity -- along with its GUID registration and zone population membership -- is stranded in the world until the server restarts. This is the mechanism behind stuck player entities after crashes: a corpse left on the ground, a player standing in a sanctuary respawn tube, or occasionally a body on a continent. Before cleaning up, if the player is not present in `inZone`, search all zones for wherever they actually are and re-point `inZone` there, so the existing four-case logout logic (in-vehicle / standing / released / absent) runs against the correct zone. When the player is found nowhere (genuinely gone), behavior is unchanged and it falls through to the existing no-op. Co-Authored-By: Claude Opus 4.8 --- .../account/AccountPersistenceService.scala | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala index 6c452e91d1..f6ce968aa9 100644 --- a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala +++ b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala @@ -366,6 +366,23 @@ class PersistenceMonitor( * but should be uncommon. */ def PerformLogout(): Unit = { + // `inZone` is the zone last reported through an `Update` heartbeat. On an abrupt disconnect + // (a crash during/after a zone transfer, or right after a respawn before an `Update` landed) + // this can be stale, so the player's live avatar/corpse actually resides in a different zone. + // Keying the lookup solely off a stale `inZone` finds nothing, falls through to the no-op case + // below, and strands the entity (with its GUID and population membership) in the world until the + // server restarts. Re-point `inZone` to wherever the player actually is before proceeding. + if (!(inZone.Players.exists(_.name == name) || inZone.AllPlayers.exists(_.Name == name))) { + Zones.zones.find { z => + z.Players.exists(_.name == name) || z.AllPlayers.exists(_.Name == name) + }.foreach { actualZone => + log.warn( + s"PerformLogout: $name was not found in the last-recorded zone ${inZone.id}; " + + s"found in ${actualZone.id} instead (likely disconnected mid-transfer). Cleaning up there." + ) + inZone = actualZone + } + } (inZone.Players.find(p => p.name == name), inZone.AllPlayers.find(p => p.Name == name)) match { case (Some(avatar), Some(player)) if player.VehicleSeated.nonEmpty => //in case the player is holding the llu and disconnects From 94e05dddaa101e2777b53a9a952b164bd8a55869 Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Mon, 20 Jul 2026 20:54:50 -0400 Subject: [PATCH 2/3] Clear residual connected state for a body-less logout; log GUID unregister failures Two follow-on gaps in PerformLogout beyond the stale-zone lookup: A body-less connected player -- released, corpse decayed, sitting at the deployment map when the client dropped -- has no player body in any zone, so even the all-zones search finds nothing and execution reaches the "user stalled" no-op. But LivePlayerList.Remove and the squad/galaxy/ population departures live only inside AvatarLogout, so the account stays listed as online (in /who, the portal, squad, galaxy) until a restart, with no visible entity. The no-op branch now resolves the avatar from LivePlayerList by name and, if still present, runs AvatarLogout to clear that residual connected state. The GUID unregister tasks (unregisterPlayer, unregisterObject for the locker) were fire-and-forget; a failure silently leaked the GUID and left the ObjectDelete incomplete until restart. Their futures are now observed and failures logged at error level so the condition is visible. Co-Authored-By: Claude Opus 4.8 --- .../account/AccountPersistenceService.scala | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala index f6ce968aa9..e820c35fda 100644 --- a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala +++ b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala @@ -21,7 +21,7 @@ import net.psforever.services.base.message.ObjectDelete import net.psforever.services.galaxy.GalaxyAction import net.psforever.zones.Zones -import scala.util.Success +import scala.util.{Failure, Success} /** * A global service that manages user behavior as divided into the following three categories: @@ -422,7 +422,21 @@ class PersistenceMonitor( AvatarLogout(avatar) case _ => - //user stalled during initial session, or was caught in between zone transfer + //No live body for this player was found in any zone, but the account may still be registered as + //connected -- e.g. the player released, their corpse decayed, and they were sitting at the deployment + //map when the client dropped, so no body exists anywhere to locate. If they are still listed as online, + //clear the residual connected state (LivePlayerList / squad / galaxy / population); otherwise they would + //linger as "connected" until a server restart even though no entity is visible in the world. + LivePlayerList.WorldPopulation({ case (_, a) => a.name.equals(name) }).headOption match { + case Some(avatar) => + log.warn( + s"PerformLogout: $name had no live body in any zone but was still registered as connected; " + + "clearing residual online state" + ) + AvatarLogout(avatar) + case None => + //user stalled during initial session, or was caught in between zone transfer + } } } @@ -453,7 +467,11 @@ class PersistenceMonitor( } inZone.Population.tell(Zone.Population.Release(avatar), parent) inZone.AvatarEvents.tell(MessageEnvelope(inZone.id, pguid, ObjectDelete(pguid)), parent) - TaskWorkflow.execute(GUIDTask.unregisterPlayer(inZone.GUID, player)) + TaskWorkflow.execute(GUIDTask.unregisterPlayer(inZone.GUID, player)).onComplete { + case Failure(e) => + log.error(s"PerformLogout: failed to unregister ${player.Name} from ${inZone.id}; GUID may leak until restart: ${e.getMessage}") + case _ => () + } //inZone.tasks.tell(GUIDTask.UnregisterPlayer(player)(inZone.GUID), parent) AvatarLogout(avatar) } @@ -474,7 +492,11 @@ class PersistenceMonitor( galaxyService.tell(MessageEnvelope(GalaxyAction.LogStatusChange(avatar.name)), context.parent) Deployables.Disown(inZone, avatar, context.parent) inZone.Population.tell(Zone.Population.Leave(avatar), context.parent) - TaskWorkflow.execute(GUIDTask.unregisterObject(inZone.GUID, avatar.locker)) + TaskWorkflow.execute(GUIDTask.unregisterObject(inZone.GUID, avatar.locker)).onComplete { + case Failure(e) => + log.error(s"PerformLogout: failed to unregister ${avatar.name}'s locker from ${inZone.id}; GUID may leak until restart: ${e.getMessage}") + case _ => () + } //inZone.tasks.tell(GUIDTask.UnregisterObjectTask(avatar.locker)(inZone.GUID), context.parent) log.info(s"Logout of ${avatar.name}") } From 5bb5f1a8a33bd0ce1ac849f62af36f68ee38c4c4 Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Wed, 22 Jul 2026 16:54:18 -0400 Subject: [PATCH 3/3] Improve comment etiquette for our previous work Reword the comments added by this branch so they describe the behaviour of the code as it now stands, rather than contrasting it with the code it replaced. A reader arriving at these files has no view of the previous implementation, so that framing carries no meaning for them; the before-and-after reasoning belongs in the commit messages, where it already is. No behavioural change: comments only. Co-Authored-By: Claude Opus 4.8 --- .../account/AccountPersistenceService.scala | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala index e820c35fda..c3f90cd928 100644 --- a/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala +++ b/src/main/scala/net/psforever/services/account/AccountPersistenceService.scala @@ -368,10 +368,10 @@ class PersistenceMonitor( def PerformLogout(): Unit = { // `inZone` is the zone last reported through an `Update` heartbeat. On an abrupt disconnect // (a crash during/after a zone transfer, or right after a respawn before an `Update` landed) - // this can be stale, so the player's live avatar/corpse actually resides in a different zone. - // Keying the lookup solely off a stale `inZone` finds nothing, falls through to the no-op case - // below, and strands the entity (with its GUID and population membership) in the world until the - // server restarts. Re-point `inZone` to wherever the player actually is before proceeding. + // it can be stale, and the player's live avatar/corpse resides in a different zone. Locate the + // entity across all zones and re-point `inZone` at wherever it actually is before proceeding, + // so the logout below acts on it rather than finding nothing and leaving it stranded in the + // world -- holding its GUID and population membership -- until the server restarts. if (!(inZone.Players.exists(_.name == name) || inZone.AllPlayers.exists(_.Name == name))) { Zones.zones.find { z => z.Players.exists(_.name == name) || z.AllPlayers.exists(_.Name == name) @@ -422,11 +422,11 @@ class PersistenceMonitor( AvatarLogout(avatar) case _ => - //No live body for this player was found in any zone, but the account may still be registered as - //connected -- e.g. the player released, their corpse decayed, and they were sitting at the deployment - //map when the client dropped, so no body exists anywhere to locate. If they are still listed as online, - //clear the residual connected state (LivePlayerList / squad / galaxy / population); otherwise they would - //linger as "connected" until a server restart even though no entity is visible in the world. + //No live body for this player exists in any zone, yet the account can still be registered as + //connected -- e.g. the player released, their corpse decayed, and they were sitting at the + //deployment map when the client dropped. Clear the residual connected state + //(LivePlayerList / squad / galaxy / population) for anyone still listed as online, so the + //account does not remain "connected" until a server restart with no entity in the world. LivePlayerList.WorldPopulation({ case (_, a) => a.name.equals(name) }).headOption match { case Some(avatar) => log.warn(