From 772ea584cef2de7ae1277779829cd0fb835b0a9b Mon Sep 17 00:00:00 2001 From: Wouter Gritter Date: Mon, 8 Jun 2026 17:04:08 +0200 Subject: [PATCH] Fix fallback chain skipped when initial server stalls after accepting connection --- .../connection/client/ConnectedPlayer.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index 47d59a7186..455185fa88 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -69,6 +69,7 @@ import com.velocitypowered.proxy.connection.util.ConnectionMessages; import com.velocitypowered.proxy.connection.util.ConnectionRequestResults.Impl; import com.velocitypowered.proxy.connection.util.VelocityInboundConnection; +import com.velocitypowered.proxy.network.Connections; import com.velocitypowered.proxy.protocol.StateRegistry; import com.velocitypowered.proxy.protocol.netty.MinecraftEncoder; import com.velocitypowered.proxy.protocol.packet.BundleDelimiterPacket; @@ -105,6 +106,7 @@ import com.velocitypowered.proxy.util.collect.CappedSet; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; +import io.netty.handler.timeout.ReadTimeoutHandler; import java.net.InetSocketAddress; import java.util.Collection; import java.util.Collections; @@ -931,6 +933,12 @@ public void setConnectedServer(@Nullable VelocityServerConnection serverConnecti if (serverConnection == connectionInFlight) { connectionInFlight = null; } + + if (serverConnection != null) { + // The player has reached a server; restore the read-timeout that was suspended while we + // were establishing their initial connection (see pauseReadTimeout / issue GemstoneGG#938). + resumeReadTimeout(); + } } public void sendLegacyForgeHandshakeResetPacket() { @@ -1439,6 +1447,33 @@ public BossBarManager getBossBarManager() { return bossBarManager; } + /** + * Suspends the read-timeout on the player's own (client-facing) connection while we establish + * their initial connection. The client legitimately idles on the loading screen during that + * window, so its read-timeout must not fire -- otherwise a backend that stalls after accepting + * the TCP connection times the idle client out before the backend connection's own timeout can + * drive the fallback chain, dropping the player instead of moving them on. Restored by + * {@link #resumeReadTimeout()} once a server is reached (issue GemstoneGG#938). + */ + private void pauseReadTimeout() { + final var pipeline = connection.getChannel().pipeline(); + if (pipeline.context(Connections.READ_TIMEOUT) != null) { + pipeline.remove(Connections.READ_TIMEOUT); + } + } + + /** + * Restores the read-timeout on the player's own connection after it was suspended by + * {@link #pauseReadTimeout()}. Idempotent: does nothing if the handler is already present. + */ + private void resumeReadTimeout() { + final var pipeline = connection.getChannel().pipeline(); + if (pipeline.context(Connections.READ_TIMEOUT) == null && pipeline.context(Connections.FRAME_DECODER) != null) { + pipeline.addAfter(Connections.FRAME_DECODER, Connections.READ_TIMEOUT, new ReadTimeoutHandler( + server.getConfiguration().getReadTimeout(), TimeUnit.MILLISECONDS)); + } + } + private final class ConnectionRequestBuilderImpl implements ConnectionRequestBuilder { private final RegisteredServer toConnect; @@ -1499,6 +1534,14 @@ private CompletableFuture internalConnect() { new VelocityServerConnection(vrs, previousServer, ConnectedPlayer.this, server); connectionInFlight = con; + if (connectedServer == null) { + // Establishing the player's initial connection (or working through the fallback chain + // for it): they have no backend yet and are idling on a loading screen. Suspend their + // connection's read-timeout so a stalled backend can't time the idle client out before + // the backend timeout drives the fallback. Restored in setConnectedServer (issue GemstoneGG#938). + pauseReadTimeout(); + } + return con.connect().whenCompleteAsync((result, exception) -> { if (result != null && !result.isSuccessful() && !result.isSafe()) { handleConnectionException(result.getAttemptedConnection(),