From 8a8faf8ffe1a9be85b6f95fd04e53e006b4a437c Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Sun, 9 Aug 2026 14:22:13 -0400 Subject: [PATCH] Fix double-dialing race and cross-server landing race Double-dial: isOpen() only becomes true once the chevron animation finishes, so a second click during that animation (another player, or the same player double-clicking) passed the existing guard and started an overlapping dial. RuntimeGate now has a separate isDialing() flag set the instant dial() starts and cleared only when closeGate() runs, so a gate stays blocked for its entire dialing-through-open lifecycle, not just while fully open. GateManager.dial() also defensively re-checks this itself and returns false if either gate is busy, in case two calls race in before the caller's own guard catches it. Cross-server landing: the proxy only sends its "deliver" message after confirming the server switch succeeded, so there's no guaranteed ordering between that message arriving and this backend finishing its own join processing for the player. A single Bukkit.getPlayer() null-check at message-receipt time could lose that race and silently drop the landing teleport, leaving the player at the server's normal spawn instead of the gate - which is what was being reported. CrossServerBridge now stashes the pending delivery and retries on PlayerJoinEvent as well, so it lands regardless of which order the message and the join actually resolve in. --- README.md | 5 +- .../stargate/paper/gate/GateManager.java | 20 +++-- .../stargate/paper/gate/RuntimeGate.java | 6 ++ .../paper/listener/SignInteractListener.java | 9 ++- .../paper/network/CrossServerBridge.java | 80 ++++++++++++++----- 5 files changed, 88 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6d58ce8..2a0ca59 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,10 @@ material, unsealed interior, too far from the sign, etc. water) and travel opens up. It auto-closes after `dialing.open-seconds`. That water is contained to exactly the iris blocks - the plugin actively cancels any attempt by Minecraft's own fluid physics to spread it further, so it can't leak out through - a gap or an odd ring shape and flood the ground around the gate. + a gap or an odd ring shape and flood the ground around the gate. A gate that's + already dialing or open won't dial again - the animation itself, not just the fully + open state, blocks re-entry - so two people clicking the same sign can't start + overlapping dials or connect it to two destinations at once. - **Shift + left-click** the sign: destroys the gate on the spot (same permission check as `/sg destroy` - owner or `stargate.admin`), breaking the sign as if you'd mined it. Quicker than looking at the sign and typing the command. diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java index e0c8d15..147dfa1 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/GateManager.java @@ -323,10 +323,16 @@ public class GateManager { // ---- Dialing ---- - /** Starts the chevron-lighting animation, then opens the gate and connects it to the destination. */ - public void dial(RuntimeGate from, RuntimeGate to, Player initiator) { - if (from.isOpen()) closeGate(from); - if (to.isOpen()) closeGate(to); + /** + * Starts the chevron-lighting animation, then opens the gate and connects it to the + * destination. Returns false (does nothing) if either gate is already dialing/open - the + * caller's own isDialing()/isOpen() check should normally catch this first, but this is a + * defensive second guard against two calls racing in before either sets the flag. + */ + public boolean dial(RuntimeGate from, RuntimeGate to, Player initiator) { + if (from.isDialing() || from.isOpen() || to.isDialing() || to.isOpen()) return false; + from.setDialing(true); + to.setDialing(true); List chevrons = from.getStructure() != null ? from.getStructure().getChevrons() : List.of(); int delay = Math.max(1, config.chevronTickDelay); @@ -340,7 +346,7 @@ public class GateManager { if (chevrons.isEmpty()) { finish.run(); - return; + return true; } final int[] i = {0}; @@ -359,6 +365,7 @@ public class GateManager { i[0]++; }, 0L, delay); from.setDialTask(taskHolder[0]); + return true; } public void openGate(RuntimeGate gate, RuntimeGate connectedTo) { @@ -386,6 +393,7 @@ public class GateManager { if (gate.getDialTask() != null) { gate.getDialTask().cancel(); gate.setDialTask(null); } if (gate.getCloseTask() != null) { gate.getCloseTask().cancel(); gate.setCloseTask(null); } gate.setOpen(false); + gate.setDialing(false); gate.setOutgoing(false); RuntimeGate other = gate.getConnectedTo(); gate.setConnectedTo(null); @@ -395,7 +403,7 @@ public class GateManager { c.getBlock().setType(c.getRestingMaterial()); } } - if (other != null && other.isOpen()) { + if (other != null && (other.isOpen() || other.isDialing())) { closeGate(other); } } diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/RuntimeGate.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/RuntimeGate.java index 87d269e..8cc0e92 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/RuntimeGate.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/gate/RuntimeGate.java @@ -9,6 +9,10 @@ public class RuntimeGate { private final Gate gate; private GateStructure structure; // null if structure could not be (re)scanned private boolean open = false; + // True from the moment dialing starts until the gate closes - NOT just while it's open. + // isOpen() alone doesn't become true until the chevron animation finishes, so guarding + // only on isOpen() lets a second click during that animation start an overlapping dial. + private boolean dialing = false; private boolean outgoing = false; // true only for the gate that was dialed - travel is one-way private RuntimeGate connectedTo = null; private BukkitTask closeTask; @@ -25,6 +29,8 @@ public class RuntimeGate { public void setStructure(GateStructure structure) { this.structure = structure; } public boolean isOpen() { return open; } public void setOpen(boolean open) { this.open = open; } + public boolean isDialing() { return dialing; } + public void setDialing(boolean dialing) { this.dialing = dialing; } public boolean isOutgoing() { return outgoing; } public void setOutgoing(boolean outgoing) { this.outgoing = outgoing; } public RuntimeGate getConnectedTo() { return connectedTo; } diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignInteractListener.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignInteractListener.java index a4563bb..0f0eeab 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignInteractListener.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/listener/SignInteractListener.java @@ -76,7 +76,7 @@ public class SignInteractListener implements Listener { } private void dial(RuntimeGate from, org.bukkit.entity.Player player) { - if (from.isOpen()) { + if (from.isOpen() || from.isDialing()) { player.sendMessage(Component.text("This gate is already active.", NamedTextColor.RED)); return; } @@ -98,7 +98,7 @@ public class SignInteractListener implements Listener { player.sendMessage(Component.text("Destination gate not found.", NamedTextColor.RED)); return; } - if (to.isOpen()) { + if (to.isOpen() || to.isDialing()) { player.sendMessage(Component.text("Destination gate is busy.", NamedTextColor.RED)); return; } @@ -109,8 +109,11 @@ public class SignInteractListener implements Listener { return; } + if (!gateManager.dial(from, to, player)) { + player.sendMessage(Component.text("This gate is busy.", NamedTextColor.RED)); + return; + } player.sendMessage(Component.text("Dialing " + to.getGate().getName() + "...", NamedTextColor.AQUA)); - gateManager.dial(from, to, player); SignRenderer.render(from, gateManager); } } diff --git a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java index 575f9b5..d1599be 100644 --- a/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java +++ b/stargate-paper/src/main/java/dev/skywalker3200/stargate/paper/network/CrossServerBridge.java @@ -7,14 +7,21 @@ import dev.skywalker3200.stargate.common.network.StargateChannel; import dev.skywalker3200.stargate.paper.StargatePlugin; import dev.skywalker3200.stargate.paper.gate.GateManager; import dev.skywalker3200.stargate.paper.gate.RuntimeGate; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.plugin.messaging.PluginMessageListener; import org.bukkit.util.Vector; import java.io.ByteArrayInputStream; import java.io.DataInputStream; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; /** @@ -22,12 +29,23 @@ import java.util.UUID; * player dialing a gate hosted on another backend server actually gets moved there and then * warped to the right spot once they land, facing the same direction they entered with. */ -public class CrossServerBridge implements PluginMessageListener { +public class CrossServerBridge implements PluginMessageListener, Listener { private final StargatePlugin plugin; private final GateManager gateManager; private boolean enabled = false; + // The proxy sends its "deliver" message only after confirming the server switch, which + // means this backend's own join processing for that player may already be running - or + // may not have started yet. There's no guaranteed ordering between "plugin message + // arrives" and "Bukkit finishes registering the player as online", so a single + // Bukkit.getPlayer() check at message-receipt time can race and silently drop the landing, + // leaving the player at whatever this server's normal spawn is. Stashing the pending + // delivery and also re-checking it on PlayerJoinEvent covers both orderings. + private final Map pending = new HashMap<>(); + + private record PendingDelivery(UUID gateId, float yaw, float pitch) {} + public CrossServerBridge(StargatePlugin plugin, GateManager gateManager) { this.plugin = plugin; this.gateManager = gateManager; @@ -37,6 +55,7 @@ public class CrossServerBridge implements PluginMessageListener { this.enabled = true; Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, StargateChannel.CHANNEL); Bukkit.getMessenger().registerIncomingPluginChannel(plugin, StargateChannel.CHANNEL, this); + Bukkit.getPluginManager().registerEvents(this, plugin); plugin.getLogger().info("[Stargate] Cross-server bridge registered on channel " + StargateChannel.CHANNEL); } @@ -70,31 +89,48 @@ public class CrossServerBridge implements PluginMessageListener { float yaw = in.readFloat(); float pitch = in.readFloat(); - RuntimeGate rg = gateManager.getById(gateId); - if (rg == null) { - plugin.getLogger().warning("[Stargate] Received teleport-deliver for unknown gate " + gateId); - return; - } - Bukkit.getScheduler().runTask(plugin, () -> { - Player p = Bukkit.getPlayer(playerId); - if (p == null) return; - if (rg.getGate().isIrisClosed()) { - // the shield closed between the request and arrival - can't send them back - // through the proxy, so just leave them at this server's default spawn - p.sendMessage(net.kyori.adventure.text.Component.text( - "The iris on the other end closed before you arrived.", - net.kyori.adventure.text.format.NamedTextColor.RED)); - return; - } - double yawRad = Math.toRadians(yaw); - Vector direction = new Vector(-Math.sin(yawRad), 0, Math.cos(yawRad)); - Location landing = gateManager.computeSafeLanding(rg, direction, yaw, pitch); - if (landing == null) return; - p.teleport(landing); + pending.put(playerId, new PendingDelivery(gateId, yaw, pitch)); + tryDeliver(playerId); + // in case the player never actually finishes joining (disconnected mid-transfer), + // don't hang onto this forever + Bukkit.getScheduler().runTaskLater(plugin, () -> pending.remove(playerId), 20L * 30); }); } catch (Exception e) { plugin.getLogger().warning("[Stargate] Failed to handle cross-server teleport message: " + e.getMessage()); } } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + if (pending.containsKey(event.getPlayer().getUniqueId())) { + tryDeliver(event.getPlayer().getUniqueId()); + } + } + + /** Must run on the main thread. No-op if the player isn't online yet or there's nothing pending for them. */ + private void tryDeliver(UUID playerId) { + PendingDelivery delivery = pending.get(playerId); + if (delivery == null) return; + Player p = Bukkit.getPlayer(playerId); + if (p == null) return; + pending.remove(playerId); + + RuntimeGate rg = gateManager.getById(delivery.gateId()); + if (rg == null) { + plugin.getLogger().warning("[Stargate] Received teleport-deliver for unknown gate " + delivery.gateId()); + return; + } + if (rg.getGate().isIrisClosed()) { + // the shield closed between the request and arrival - can't send them back + // through the proxy, so just leave them at this server's default spawn + p.sendMessage(Component.text("The iris on the other end closed before you arrived.", NamedTextColor.RED)); + return; + } + double yawRad = Math.toRadians(delivery.yaw()); + Vector direction = new Vector(-Math.sin(yawRad), 0, Math.cos(yawRad)); + Location landing = gateManager.computeSafeLanding(rg, direction, delivery.yaw(), delivery.pitch()); + if (landing == null) return; + p.teleport(landing); + } }