From 0061c0f2f78ef9dcbbd0c2793cebc68333b5b3b7 Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Mon, 20 Jul 2026 13:11:39 -0400 Subject: [PATCH 1/5] Run zoning respawn/countdown timers on the actor thread The respawnTimer (LoadZonePhysicalSpawnPoint) and zoningTimer (beginZoningCountdown) used the closure form of scheduleOnce, so their bodies ran on the global ForkJoinPool rather than serialized with actor message processing. During rapid/frequent zoning this raced the actor: - respawnTimer.cancel()/zoningTimer.cancel() are best-effort and cannot stop an already-dispatched closure, so a superseded transfer's timer still fired; - the respawn closure read live, non-volatile session state (player/continent/interstellarFerry) off-thread, producing ObjectDelete/ObjectCreate against a stale/half-updated GUID space and crashing the client. Convert both to the message-delivery form scheduleOnce(delay, self, msg), mirroring the existing SetCurrentAvatar pattern. The countdown continuation and the resolved respawn destination travel as ZoningCountdownTick / ZoningSpawnPointRespawn messages handled on the actor thread, making the cancel() guards genuinely effective and the state reads consistent. Co-Authored-By: Claude Opus 4.8 --- .../actors/session/SessionActor.scala | 6 + .../session/support/ZoningOperations.scala | 126 ++++++++++++------ 2 files changed, 94 insertions(+), 38 deletions(-) diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala index 29bee0368..8996fdda0 100644 --- a/src/main/scala/net/psforever/actors/session/SessionActor.scala +++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala @@ -298,6 +298,12 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con case ZoningOperations.AvatarAwardMessageBundle(pkts, delay) => data.zoning.spawn.performAvatarAwardMessageDelivery(pkts, delay) + case ZoningOperations.ZoningCountdownTick(runnable) => + data.zoning.beginZoningCountdown(runnable) + + case msg: ZoningOperations.ZoningSpawnPointRespawn => + data.zoning.spawn.handleZoningSpawnPointRespawn(msg) + case CommonMessages.ProgressEvent(delta, finishedAction, stepAction, tick) => data.general.handleProgressChange(delta, finishedAction, stepAction, tick) diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index 6ffe99ebb..af7ce5db0 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -87,6 +87,37 @@ object ZoningOperations { delay: Long ) + /** + * Delivered to the session actor when a zoning countdown timer elapses. + * Carrying the countdown continuation as a message (rather than executing it inside the timer closure) + * guarantees the countdown logic runs on the actor thread, serialized with all other session activity, + * so that a concurrent transfer's `zoningTimer.cancel()` is actually effective. + * @param runnable the next step of the zoning countdown to perform + */ + private[session] final case class ZoningCountdownTick(runnable: Runnable) + + /** + * Delivered to the session actor when a physical spawn-point respawn timer elapses. + * Carrying the resolved destination as a message (rather than executing it inside the timer closure) + * guarantees the respawn/zone-load logic runs on the actor thread, serialized with all other session activity, + * so that a concurrent transfer's `respawnTimer.cancel()` is actually effective and the closure can no longer + * read a half-updated `session`/`continent`/`interstellarFerry` off the global thread pool. + * @param zoneId the destination zone designation + * @param position where in the destination zone to place the avatar + * @param orientation the direction the avatar faces at the destination + * @param side the containment side of the destination spawn + * @param toZoneNumber the destination zone number, resolved at scheduling time + * @param physSpawnPoint the physical spawn point, if any + */ + private[session] final case class ZoningSpawnPointRespawn( + zoneId: String, + position: Vector3, + orientation: Vector3, + side: Sidedness, + toZoneNumber: Int, + physSpawnPoint: Option[SpawnPoint] + ) + private final val zoningCountdownMessages: Seq[Int] = Seq(5, 10, 20) def reportProgressionSystem(logic: SessionData): Unit = { @@ -938,9 +969,11 @@ class ZoningOperations( zoningCounter = time sendResponse(ChatMsg(ChatMessageType.CMT_QUIT, wideContents=false, "", s"@${descriptor}_$origin", None)) zoningTimer.cancel() - zoningTimer = context.system.scheduler.scheduleOnce(5 seconds) { - beginZoningCountdown(runnable) - } + zoningTimer = context.system.scheduler.scheduleOnce( + 5 seconds, + context.self, + ZoningOperations.ZoningCountdownTick(runnable) + ) } else if (zoningStatus == Zoning.Status.Countdown) { zoningCounter -= 5 zoningTimer.cancel() @@ -948,9 +981,11 @@ class ZoningOperations( if (ZoningOperations.zoningCountdownMessages.contains(zoningCounter)) { sendResponse(ChatMsg(zoningChatMessageType, wideContents=false, "", s"@${descriptor}_$zoningCounter", None)) } - zoningTimer = context.system.scheduler.scheduleOnce(5 seconds) { - beginZoningCountdown(runnable) - } + zoningTimer = context.system.scheduler.scheduleOnce( + 5 seconds, + context.self, + ZoningOperations.ZoningCountdownTick(runnable) + ) } else { zoningCounter = 0 //zoning deployment @@ -3157,40 +3192,55 @@ class ZoningOperations( case _ => Sidedness.StrictlyBetweenSides //todo needs better determination } + respawnTimer = context.system.scheduler.scheduleOnce( + respawnTime, + context.self, + ZoningOperations.ZoningSpawnPointRespawn(zoneId, pos, ori, toSide, toZoneNumber, physSpawnPoint) + ) + } + + /** + * Perform the deferred respawn / zone-load that was scheduled by `LoadZonePhysicalSpawnPoint`. + * This runs on the actor thread (delivered as `ZoningOperations.ZoningSpawnPointRespawn`) + * rather than inside a timer closure on the global thread pool, + * so that it observes a consistent `session`/`continent`/`interstellarFerry` + * and is genuinely cancellable by a superseding transfer. + * @see `LoadZonePhysicalSpawnPoint` + */ + def handleZoningSpawnPointRespawn(msg: ZoningOperations.ZoningSpawnPointRespawn): Unit = { + val ZoningOperations.ZoningSpawnPointRespawn(zoneId, pos, ori, toSide, toZoneNumber, physSpawnPoint) = msg val toSpawnPoint = physSpawnPoint.collect { case o: PlanetSideGameObject with FactionAffinity => SourceEntry(o) } - respawnTimer = context.system.scheduler.scheduleOnce(respawnTime) { - if (!player.isAlive && player.History.nonEmpty) { // if the player is dead, handle as dead infantry, even if dead in a vehicle - // new player is spawning - val newPlayer = RespawnClone(player) - newPlayer.LogActivity(SpawningActivity(PlayerSource(newPlayer), toZoneNumber, toSpawnPoint)) - LoadZoneAsPlayerUsing(newPlayer, pos, ori, toSide, zoneId) - } else { - avatarActor ! AvatarActor.DeactivateActiveImplants - val betterSpawnPoint = physSpawnPoint.collect { case o: PlanetSideGameObject with FactionAffinity with InGameHistory => o } - interstellarFerry.orElse(continent.GUID(player.VehicleSeated)) match { - case Some(vehicle: Vehicle) => // driver or passenger in vehicle using a warp gate, or a droppod - InGameHistory.SpawnReconstructionActivity(vehicle, toZoneNumber, betterSpawnPoint) - InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) - vehicle.WhichSide = toSide - LoadZoneInVehicle(vehicle, pos, ori, zoneId) - - case _ if player.HasGUID => // player is deconstructing self or instant action - val player_guid = player.GUID - // entering or exiting VR zones uses a fade-out effect for the player instead of the usual green cloud deconstruction effect - val effect = if (player.IsInVRZone || zoneId.startsWith("tz")) 2 else 1 - sendResponse(ObjectDeleteMessage(player_guid, unk1=effect)) - continent.AvatarEvents ! MessageEnvelope( - continent.id, - player_guid, - ObjectDelete(player_guid, unk=effect) - ) - InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) - LoadZoneAsPlayerUsing(player, pos, ori, toSide, zoneId) + if (!player.isAlive && player.History.nonEmpty) { // if the player is dead, handle as dead infantry, even if dead in a vehicle + // new player is spawning + val newPlayer = RespawnClone(player) + newPlayer.LogActivity(SpawningActivity(PlayerSource(newPlayer), toZoneNumber, toSpawnPoint)) + LoadZoneAsPlayerUsing(newPlayer, pos, ori, toSide, zoneId) + } else { + avatarActor ! AvatarActor.DeactivateActiveImplants + val betterSpawnPoint = physSpawnPoint.collect { case o: PlanetSideGameObject with FactionAffinity with InGameHistory => o } + interstellarFerry.orElse(continent.GUID(player.VehicleSeated)) match { + case Some(vehicle: Vehicle) => // driver or passenger in vehicle using a warp gate, or a droppod + InGameHistory.SpawnReconstructionActivity(vehicle, toZoneNumber, betterSpawnPoint) + InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) + vehicle.WhichSide = toSide + LoadZoneInVehicle(vehicle, pos, ori, zoneId) + + case _ if player.HasGUID => // player is deconstructing self or instant action + val player_guid = player.GUID + // entering or exiting VR zones uses a fade-out effect for the player instead of the usual green cloud deconstruction effect + val effect = if (player.IsInVRZone || zoneId.startsWith("tz")) 2 else 1 + sendResponse(ObjectDeleteMessage(player_guid, unk1=effect)) + continent.AvatarEvents ! MessageEnvelope( + continent.id, + player_guid, + ObjectDelete(player_guid, unk=effect) + ) + InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) + LoadZoneAsPlayerUsing(player, pos, ori, toSide, zoneId) - case _ => //player is logging in - InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) - LoadZoneAsPlayerUsing(player, pos, ori, toSide, zoneId) - } + case _ => //player is logging in + InGameHistory.SpawnReconstructionActivity(player, toZoneNumber, betterSpawnPoint) + LoadZoneAsPlayerUsing(player, pos, ori, toSide, zoneId) } } } From 25606e05756412eb2d8f7535d55a2791d801623a Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Mon, 20 Jul 2026 13:21:00 -0400 Subject: [PATCH 2/5] Discard superseded spawn-point responses and duplicate zoning requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rapid/frequent zoning produced two client-crashing packet sequences: 1. Stale SpawnPointResponse force-load. A spawn-point request to the cluster is async and unordered; when a player started transfer B before transfer A's response arrived, the handler's default branch applied A's (wrong-zone) spawn under B's intent, sending a LoadMapMessage/spawn for the wrong zone. The "zoning was not in order" branch recognised the disorder but still called resolveZoningSpawnPointLoad. Add an opaque correlation token to the ICS Get*SpawnPoint requests, echoed verbatim in SpawnPointResponse. Each session-originated request is stamped via nextSpawnPointToken() (monotonic per session). handleSpawnPointResponse discards any response whose non-zero token is not the latest issued — i.e. a strictly newer request has superseded it. This never drops the most recent request's response, so it cannot soft-lock a legitimate transfer; token 0 (non-session callers) is always accepted, preserving existing behaviour. 2. Duplicate ObjectCreate from a second BeginZoningMessage. Two rapid transfers queue two BeginZoningMessages; the second re-dumped ObjectCreate for every entity in a zone the client had already loaded ("object already exists" -> crash). handleBeginZoning now ignores a BeginZoningMessage when zoneLoaded is already Some(true); a legitimate new load always resets zoneLoaded to None first. Co-Authored-By: Claude Opus 4.8 --- .../actors/session/SessionActor.scala | 4 +- .../session/normal/LocalHandlerLogic.scala | 3 +- .../session/support/ZoningOperations.scala | 71 +++++++++++++++---- .../services/InterstellarClusterService.scala | 47 +++++++----- 4 files changed, 90 insertions(+), 35 deletions(-) diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala index 29bee0368..ce7893c8f 100644 --- a/src/main/scala/net/psforever/actors/session/SessionActor.scala +++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala @@ -230,8 +230,8 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con logic.general.handleRenewCharSavedTimerMsg() /* common messages (maybe once every respawn) */ - case ICS.SpawnPointResponse(response) => - data.zoning.handleSpawnPointResponse(response) + case ICS.SpawnPointResponse(response, token) => + data.zoning.handleSpawnPointResponse(response, token) case SessionActor.NewPlayerLoaded(tplayer) => data.zoning.spawn.handleNewPlayerLoaded(tplayer) diff --git a/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala index 003a60b08..68b43134e 100644 --- a/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/LocalHandlerLogic.scala @@ -245,7 +245,8 @@ class LocalHandlerLogic(val ops: SessionLocalHandlers, implicit val context: Act zone.Number, player.Faction, Seq(SpawnGroup.Facility, SpawnGroup.Tower, SpawnGroup.AMS), - context.self + context.self, + token = sessionLogic.zoning.nextSpawnPointToken() ) } } diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index 6ffe99ebb..50399c809 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -217,6 +217,14 @@ class ZoningOperations( * `Some(false)` when the loading process has failed or was executed but did not complete for some reason */ private[session] var zoneLoaded: Option[Boolean] = None + /** + * a monotonically increasing correlation counter for outgoing spawn-point requests to the cluster service. + * Every session-originated `Get*SpawnPoint` request is stamped with a fresh value via `nextSpawnPointToken()`, + * and the value is echoed back in the `SpawnPointResponse`. + * A response whose token is older than the latest issued token has been superseded by a newer request + * (the classic rapid-zoning race) and is discarded rather than force-loaded into a mismatched zone. + */ + private var spawnPointRequestToken: Long = 0L /** * used during zone transfers to maintain reference to seated vehicle (which does not yet exist in the new zone) * used during intrazone gate transfers, but not in a way distinct from prior zone transfer procedures @@ -267,7 +275,8 @@ class ZoningOperations( destinationBuildingGuid, continent.Number, building_guid, - context.self + context.self, + token = nextSpawnPointToken() ) log.info(s"${player.Name} wants to use a warp gate") @@ -295,6 +304,15 @@ class ZoningOperations( def handleBeginZoning(pkt: BeginZoningMessage): Unit = { val BeginZoningMessage() = pkt + if (zoneLoaded.contains(true)) { + //the current zone has already finished loading; this is a duplicate BeginZoningMessage + //(e.g. a second one queued behind a rapid transfer). Re-dumping ObjectCreate for entities + //the client already holds triggers "object already exists" and crashes it, so ignore it. + //A legitimate new zone load always resets zoneLoaded to None first (LoadZoneCommonTransferActivity). + log.warn( + s"BeginZoningMessage: ${player.Name} sent a duplicate zoning request for an already-loaded ${continent.id}; ignoring" + ) + } else { log.trace(s"BeginZoningMessage: ${player.Name} is reticulating ${continent.id}'s splines ...") zoneLoaded = None val name = avatar.name @@ -557,6 +575,7 @@ class ZoningOperations( ) spawn.upstreamMessageCount = 0 zoneLoaded = Some(true) + } } /* messages */ @@ -640,7 +659,8 @@ class ZoningOperations( player.Faction, player.Position, Seq(SpawnGroup.Facility, SpawnGroup.Tower), - context.self + context.self, + token = nextSpawnPointToken() ) } } @@ -668,14 +688,31 @@ class ZoningOperations( } } - def handleSpawnPointResponse(response: Option[(Zone, SpawnPoint)]): Unit = { + /** + * Produce the next correlation token for a session-originated spawn-point request. + * @see `spawnPointRequestToken` + * @see `handleSpawnPointResponse` + */ + private[session] def nextSpawnPointToken(): Long = { + spawnPointRequestToken += 1 + spawnPointRequestToken + } + + def handleSpawnPointResponse(response: Option[(Zone, SpawnPoint)], token: Long): Unit = { + if (token != 0L && token != spawnPointRequestToken) { + //a newer spawn-point request has since been issued; this response belongs to a superseded transfer + log.debug( + s"SpawnPointResponse: ${player.Name} received a superseded spawn-point response " + + s"(token $token, current $spawnPointRequestToken); discarding to avoid loading a mismatched zone" + ) + } else { zoningType match { case Zoning.Method.InstantAction if response.isEmpty => CancelZoningProcessWithReason("@InstantActionNoHotspotsAvailable") case Zoning.Method.InstantAction if zoningStatus == Zoning.Status.Request => beginZoningCountdown(() => { - cluster ! ICS.GetInstantActionSpawnPoint(player.Faction, context.self) + cluster ! ICS.GetInstantActionSpawnPoint(player.Faction, context.self, nextSpawnPointToken()) }) case Zoning.Method.Reset => @@ -713,6 +750,7 @@ class ZoningOperations( continent.Population ! Zone.Population.Release(avatar) spawn.resolveZoningSpawnPointLoad(response, previousZoningType) } + } } def handleTransferPassenger( @@ -797,7 +835,8 @@ class ZoningOperations( Zones.sanctuaryZoneNumber(player.Faction), player.Faction, Seq(SpawnGroup.Sanctuary), - context.self + context.self, + token = nextSpawnPointToken() ) }) } @@ -812,7 +851,7 @@ class ZoningOperations( zoningType = Zoning.Method.InstantAction zoningChatMessageType = ChatMessageType.CMT_INSTANTACTION zoningStatus = Zoning.Status.Request - cluster ! ICS.GetInstantActionSpawnPoint(player.Faction, context.self) + cluster ! ICS.GetInstantActionSpawnPoint(player.Faction, context.self, nextSpawnPointToken()) } } @@ -1578,14 +1617,16 @@ class ZoningOperations( Zones.sanctuaryZoneNumber(player.Faction), player.Faction, Seq(SpawnGroup.WarpGate), - context.self + context.self, + token = nextSpawnPointToken() ) case _ => cluster ! ICS.GetRandomSpawnPoint( Zones.sanctuaryZoneNumber(player.Faction), player.Faction, Seq(SpawnGroup.Sanctuary), - context.self + context.self, + token = nextSpawnPointToken() ) } } @@ -2044,7 +2085,8 @@ class ZoningOperations( player.Faction, shiftPosition.getOrElse(player.Position), Seq(spawnGroup), - context.self + context.self, + token = nextSpawnPointToken() ) shiftPosition = None } else { @@ -2271,7 +2313,7 @@ class ZoningOperations( case Zoning.Method.InstantAction if zoningStatus == Zoning.Status.Request => beginZoningCountdown(() => { - cluster ! ICS.GetInstantActionSpawnPoint(player.Faction, context.self) + cluster ! ICS.GetInstantActionSpawnPoint(player.Faction, context.self, nextSpawnPointToken()) }) case Zoning.Method.Reset => @@ -2360,7 +2402,8 @@ class ZoningOperations( tplayer.Faction, tplayer.Position, Seq(SpawnGroup.Facility, SpawnGroup.Tower, SpawnGroup.AMS), - context.self + context.self, + token = nextSpawnPointToken() ) } } @@ -2396,7 +2439,8 @@ class ZoningOperations( tplayer.Faction, tplayer.Position, Seq(SpawnGroup.Facility, SpawnGroup.Tower, SpawnGroup.AMS), - context.self + context.self, + token = nextSpawnPointToken() ) } } @@ -4039,7 +4083,8 @@ class ZoningOperations( Zones.sanctuaryZoneNumber(faction), faction, Seq(SpawnGroup.Sanctuary), - context.self + context.self, + token = nextSpawnPointToken() ) } } diff --git a/src/main/scala/net/psforever/services/InterstellarClusterService.scala b/src/main/scala/net/psforever/services/InterstellarClusterService.scala index 30a02008f..b6d94d77d 100644 --- a/src/main/scala/net/psforever/services/InterstellarClusterService.scala +++ b/src/main/scala/net/psforever/services/InterstellarClusterService.scala @@ -46,8 +46,14 @@ object InterstellarClusterService { final case class ZonesResponse(zoneActor: Iterable[Zone]) - final case class GetInstantActionSpawnPoint(faction: PlanetSideEmpire.Value, replyTo: ActorRef[SpawnPointResponse]) - extends Command + // `token` is an opaque correlation value echoed back verbatim in the SpawnPointResponse. + // The requesting session uses it to discard responses that a newer spawn-point request has superseded + // (see ZoningOperations.handleSpawnPointResponse). Non-session callers may leave it at the default 0L. + final case class GetInstantActionSpawnPoint( + faction: PlanetSideEmpire.Value, + replyTo: ActorRef[SpawnPointResponse], + token: Long = 0L + ) extends Command final case class GetSpawnPoint( zoneNumber: Int, @@ -55,7 +61,8 @@ object InterstellarClusterService { target: PlanetSideGUID, fromZoneNumber: Int, fromGateGuid: PlanetSideGUID, - replyTo: ActorRef[SpawnPointResponse] + replyTo: ActorRef[SpawnPointResponse], + token: Long = 0L ) extends Command final case class GetNearbySpawnPoint( @@ -63,17 +70,19 @@ object InterstellarClusterService { faction: PlanetSideEmpire.Value, position: Vector3, spawnGroups: Seq[SpawnGroup], - replyTo: ActorRef[SpawnPointResponse] + replyTo: ActorRef[SpawnPointResponse], + token: Long = 0L ) extends Command final case class GetRandomSpawnPoint( zoneNumber: Int, faction: PlanetSideEmpire.Value, spawnGroups: Seq[SpawnGroup], - replyTo: ActorRef[SpawnPointResponse] + replyTo: ActorRef[SpawnPointResponse], + token: Long = 0L ) extends Command - final case class SpawnPointResponse(response: Option[(Zone, SpawnPoint)]) + final case class SpawnPointResponse(response: Option[(Zone, SpawnPoint)], token: Long = 0L) final case class GetPlayers(replyTo: ActorRef[PlayersResponse]) extends Command @@ -165,7 +174,7 @@ class InterstellarClusterService(context: ActorContext[InterstellarClusterServic case FilterZones(predicate, replyTo) => replyTo ! ZonesResponse(zones.filter(predicate)) - case GetInstantActionSpawnPoint(faction, replyTo) => + case GetInstantActionSpawnPoint(faction, replyTo, token) => val spawnTarget: Seq[SpawnGroup] = if (Config.app.game.instantAction.spawnOnAms) { Seq(SpawnGroup.Tower, SpawnGroup.Facility, SpawnGroup.AMS) } else { @@ -203,9 +212,9 @@ class InterstellarClusterService(context: ActorContext[InterstellarClusterServic val spawnPoint = spawns.minBy(point => Vector3.DistanceSquared(point.Position, pos)) (zone, spawnPoint) } - replyTo ! SpawnPointResponse(spawnResults) + replyTo ! SpawnPointResponse(spawnResults, token) - case GetRandomSpawnPoint(zoneNumber, faction, spawnGroups, replyTo) => + case GetRandomSpawnPoint(zoneNumber, faction, spawnGroups, replyTo, token) => val response = zones.find(_.Number == zoneNumber) match { case Some(zone: Zone) => Random.shuffle(zone.findSpawns(faction, spawnGroups)).headOption match { @@ -218,9 +227,9 @@ class InterstellarClusterService(context: ActorContext[InterstellarClusterServic log.error(s"no zone $zoneNumber") None } - replyTo ! SpawnPointResponse(response) + replyTo ! SpawnPointResponse(response, token) - case GetSpawnPoint(zoneNumber, faction, target, fromZoneNumber, fromOriginGuid, replyTo) => + case GetSpawnPoint(zoneNumber, faction, target, fromZoneNumber, fromOriginGuid, replyTo, token) => zones.find(_.Number == zoneNumber) match { case Some(zone) => //found target zone; find a spawn point in target zone @@ -233,10 +242,10 @@ class InterstellarClusterService(context: ActorContext[InterstellarClusterServic } match { case Some((_, spawnPoints)) => //spawn point selected - replyTo ! SpawnPointResponse(Some(zone, Random.shuffle(spawnPoints.toList).head)) + replyTo ! SpawnPointResponse(Some(zone, Random.shuffle(spawnPoints.toList).head), token) case _ => //no spawn point found - replyTo ! SpawnPointResponse(None) + replyTo ! SpawnPointResponse(None, token) } case None => //target zone not found; find origin and plot next immediate destination @@ -253,24 +262,24 @@ class InterstellarClusterService(context: ActorContext[InterstellarClusterServic }) match { case Some(outputGate: WarpGate) => //destination (next direct stopping point) found - replyTo ! SpawnPointResponse(Some(outputGate.Zone, outputGate)) + replyTo ! SpawnPointResponse(Some(outputGate.Zone, outputGate), token) case _ => //no destination found - replyTo ! SpawnPointResponse(None) + replyTo ! SpawnPointResponse(None, token) } } - case GetNearbySpawnPoint(zoneNumber, faction, position, spawnGroups, replyTo) => + case GetNearbySpawnPoint(zoneNumber, faction, position, spawnGroups, replyTo, token) => zones.find(_.Number == zoneNumber) match { case Some(zone) => zone.findNearestSpawnPoints(faction, position, spawnGroups) match { case None | Some(Nil) => - replyTo ! SpawnPointResponse(None) + replyTo ! SpawnPointResponse(None, token) case Some(spawnPoints) => - replyTo ! SpawnPointResponse(Some(zone, scala.util.Random.shuffle(spawnPoints).head)) + replyTo ! SpawnPointResponse(Some(zone, scala.util.Random.shuffle(spawnPoints).head), token) } case None => - replyTo ! SpawnPointResponse(None) + replyTo ! SpawnPointResponse(None, token) } case DroppodLaunchRequest(zoneNumber, position, faction, replyTo) => From 083c4ac237f1ae9fe4c9cda1da2ba54a8dfa060a Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Mon, 20 Jul 2026 13:25:31 -0400 Subject: [PATCH 3/5] Clear the transient interstellarFerry once the avatar respawns interstellarFerry holds the seated vehicle across a zone transfer (the vehicle does not yet exist in the destination zone's GUID space). It is set on a vehicle/droppod transfer but was only reset to None in the vehicle branch of AvatarCreate. When a transfer that set it instead resolved to an on-foot spawn (droppod landing, a vehicle transfer that ended with the player unseated), the infantry and spectator branches left it set. A subsequent infantry re-zone then read that stale reference at the head of LoadZonePhysicalSpawnPoint (interstellarFerry.orElse(...)) and routed into LoadZoneInVehicle against a vehicle GUID absent from the destination zone -- an ObjectCreate attaching the avatar to a phantom parent, which crashes the client. The field's own doc-comment warns that leaving it set "prior to a subsequent transfer may cause unstable vehicle associations, with memory leak potential." Reset interstellarFerry unconditionally once the avatar has been created in the new zone, so no later transfer can inherit a stale vehicle association. The intra-transfer unregister->register ordering is already sequenced by taskThenZoneChange (the unregister subtask runs before the FindZone that drives registration), and handleZoneResponse already guards its driven-vehicle path with a seat check; this closes the remaining stale-reference route. Co-Authored-By: Claude Opus 4.8 --- .../psforever/actors/session/support/ZoningOperations.scala | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index 6ffe99ebb..fac248fb3 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -2663,6 +2663,12 @@ class ZoningOperations( AvatarAction.LoadPlayer(definition.ObjectId, guid, definition.Packet.ConstructorData(player).get, None) ) } + //The transient inter-zone vehicle reference has served its purpose now that the avatar exists in the new zone. + //The vehicle branch clears it above, but the infantry/spectator branches did not, so a stale ferry could survive + //(e.g. after a droppod or a vehicle transfer that resolved to an on-foot spawn) and misroute the NEXT transfer + //into LoadZoneInVehicle against a vehicle absent from the destination zone, crashing the client. + //Drop it unconditionally here so subsequent transfers cannot inherit a stale vehicle association. + interstellarFerry = None continent.Population ! Zone.Population.Spawn(avatar, player, avatarActor) avatarActor ! AvatarActor.RefreshPurchaseTimes() drawDeployableIconsOnMap( From 67fb28c12fbae8d8a15dc28289bf5dfc6acd3dd0 Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Mon, 20 Jul 2026 13:37:02 -0400 Subject: [PATCH 4/5] Make SMP retransmit history configurable and large enough for zone loads The middleware retained only the last 100 reliable SlottedMetaPackets for retransmission (smpHistoryLength). A zone load bursts a large number of reliable ObjectCreate packets; when that burst exceeds the window and the client requests a resend (RelatedA) of an evicted subslot, the packet is gone ("no longer logged") and the client permanently misses reliable data, wedging or crashing it. Frequent zoning made this routine because each transfer is a fresh burst with no quiet period to drain the buffer. - Promote smpHistoryLength to network.middleware.smp-history-length in config (default 1024), so it comfortably exceeds a single-zone burst and can be tuned per deployment. Cost is ~(length * MTU) bytes per session. - Make the RelatedA lookup null-safe: the history ring holds null slots until it fills, and .find(_.subslot == ...) dereferenced them, so a not-found request before the ring filled could NPE instead of falling through to the not-found branches (more exposed with a larger ring). - Make the eviction warning actionable by naming the tunable. Verified Config parses the new field at runtime (smpHistoryLength = 1024). Co-Authored-By: Claude Opus 4.8 --- src/main/resources/application.conf | 8 ++++++++ .../net/psforever/actors/net/MiddlewareActor.scala | 10 +++++++--- src/main/scala/net/psforever/util/Config.scala | 3 ++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 765c800fe..80b2f0b03 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -564,6 +564,14 @@ network { # How many attempts at resolving missing packets with anticipated subslot numbers in-subslot-missing-attempts = 10 + + # How many recently-sent reliable SlottedMetaPackets are retained for retransmission. + # When the client requests a resend (RelatedA) of a subslot older than this window, the packet + # has been evicted and cannot be recovered, wedging the client. A zone load bursts a large number + # of reliable ObjectCreate packets, so this must comfortably exceed the biggest single-zone burst; + # the old fixed value of 100 was routinely overrun during frequent zoning. + # Cost is roughly (length * MTU) bytes of retained packet data per session. + smp-history-length = 1024 } session { diff --git a/src/main/scala/net/psforever/actors/net/MiddlewareActor.scala b/src/main/scala/net/psforever/actors/net/MiddlewareActor.scala index 5f56491d3..b0afd0d97 100644 --- a/src/main/scala/net/psforever/actors/net/MiddlewareActor.scala +++ b/src/main/scala/net/psforever/actors/net/MiddlewareActor.scala @@ -274,7 +274,7 @@ class MiddlewareActor( MiddlewareActor.propertyOverrideMessageGuard ) - private val smpHistoryLength: Int = 100 + private val smpHistoryLength: Int = Config.app.network.middleware.smpHistoryLength /** History of created `SlottedMetaPacket`s. * In case the client does not register receiving a packet by checking against packet subslot index numbers, @@ -580,11 +580,15 @@ class MiddlewareActor( case RelatedA(slot, subslot) => val requestedSubslot = subslot - 1 - preparedSlottedMetaPackets.find(_.subslot == requestedSubslot) match { + //the history ring is pre-sized and holds null slots until it fills, so the predicate must be null-safe + preparedSlottedMetaPackets.find(p => p != null && p.subslot == requestedSubslot) match { case Some(_packet) => outQueueBundled.enqueue(_packet) case None if requestedSubslot < acceptedSmpSubslot => - log.warn(s"Client indicated an smp of slot $slot prior to $subslot that is no longer logged") + log.warn( + s"Client indicated an smp of slot $slot prior to $subslot that is no longer logged " + + s"(retransmit history holds $smpHistoryLength packets; raise network.middleware.smp-history-length if this recurs)" + ) case None => log.warn(s"Client indicated an smp of slot $slot prior to $subslot that is not found") } diff --git a/src/main/scala/net/psforever/util/Config.scala b/src/main/scala/net/psforever/util/Config.scala index f32c2adaf..228b80f3f 100644 --- a/src/main/scala/net/psforever/util/Config.scala +++ b/src/main/scala/net/psforever/util/Config.scala @@ -140,7 +140,8 @@ case class MiddlewareConfig( packetBundlingDelayMultiplier: Float, inReorderTimeout: FiniteDuration, inSubslotMissingDelay: FiniteDuration, - inSubslotMissingAttempts: Int + inSubslotMissingAttempts: Int, + smpHistoryLength: Int ) case class SessionConfig( From 12467396ea9ba04eadf6c29452f74553706686da Mon Sep 17 00:00:00 2001 From: GeekOfWires Date: Wed, 22 Jul 2026 16:54:18 -0400 Subject: [PATCH 5/5] 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 and one configuration comment only. Co-Authored-By: Claude Opus 4.8 --- src/main/resources/application.conf | 4 ++-- .../actors/session/support/ZoningOperations.scala | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 80b2f0b03..98f23ce3c 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -568,8 +568,8 @@ network { # How many recently-sent reliable SlottedMetaPackets are retained for retransmission. # When the client requests a resend (RelatedA) of a subslot older than this window, the packet # has been evicted and cannot be recovered, wedging the client. A zone load bursts a large number - # of reliable ObjectCreate packets, so this must comfortably exceed the biggest single-zone burst; - # the old fixed value of 100 was routinely overrun during frequent zoning. + # of reliable ObjectCreate packets, so this must comfortably exceed the biggest single-zone + # burst; frequent zoning is the case that drives the requirement. # Cost is roughly (length * MTU) bytes of retained packet data per session. smp-history-length = 1024 } diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index a2bbdbc26..567141fc2 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -89,19 +89,19 @@ object ZoningOperations { /** * Delivered to the session actor when a zoning countdown timer elapses. - * Carrying the countdown continuation as a message (rather than executing it inside the timer closure) - * guarantees the countdown logic runs on the actor thread, serialized with all other session activity, - * so that a concurrent transfer's `zoningTimer.cancel()` is actually effective. + * The countdown continuation travels as a message so that the countdown logic runs on the actor + * thread, serialized with all other session activity, which is what makes a concurrent transfer's + * `zoningTimer.cancel()` effective. * @param runnable the next step of the zoning countdown to perform */ private[session] final case class ZoningCountdownTick(runnable: Runnable) /** * Delivered to the session actor when a physical spawn-point respawn timer elapses. - * Carrying the resolved destination as a message (rather than executing it inside the timer closure) - * guarantees the respawn/zone-load logic runs on the actor thread, serialized with all other session activity, - * so that a concurrent transfer's `respawnTimer.cancel()` is actually effective and the closure can no longer - * read a half-updated `session`/`continent`/`interstellarFerry` off the global thread pool. + * The resolved destination travels as a message so that the respawn/zone-load logic runs on the + * actor thread, serialized with all other session activity. That is what makes a concurrent + * transfer's `respawnTimer.cancel()` effective, and it keeps `session`/`continent`/ + * `interstellarFerry` from being read in a half-updated state off the global thread pool. * @param zoneId the destination zone designation * @param position where in the destination zone to place the avatar * @param orientation the direction the avatar faces at the destination