Expected Behavior
Plugin messages sent on registered channels during the player join process should be forwarded to the backend server via getConnectionInFlight() when getConnectedServer() is null, the same way Forge Legacy handshake messages are. Messages should not be silently discarded.
Actual Behavior
ClientPlaySessionHandler.handle(PluginMessagePacket) at lines 303 to 310 only falls back to getConnectionInFlight() when the channel is LegacyForgeConstants.FORGE_LEGACY_HANDSHAKE_CHANNEL. For all other channels, when getConnectedServer() returns null during the join window, serverConn is set to null and the handler silently skips the message. No warning, no error, no exception, etc.
VelocityServerConnection serverConn =
(player.getConnectedServer() == null
&& packet.getChannel().equals(
LegacyForgeConstants.FORGE_LEGACY_HANDSHAKE_CHANNEL))
? player.getConnectionInFlight() : player.getConnectedServer();
The null window exists between player.setConnectedServer(null) at TransitionSessionHandler line 100 and player.setConnectedServer(serverConn) at line 140 (inside a thenRunAsync callback). During this window the client connection continues processing packets, but any non-Forge plugin message is dropped.
This is the same null window documented in issue #569, where ServerConnectedEvent is described as "fatally flawed" because Player#getCurrentServer() returns null during it.
Steps to Reproduce
- Register a custom plugin message channel on Velocity
- Have a plugin or client mod send a plugin message on that channel during the join process, while
getConnectedServer() is still null (e.g. a fast async callback such as a cached response completing during the join window)
- Observe that the message is silently dropped with no log output
The bug is intermittent. It only triggers when the message arrives during the null window between setConnectedServer(null) and setConnectedServer(serverConn). A delayed retry reliably succeeds.
Plugin List
N/A - reproducible with any plugin that sends messages on a custom channel during the join process.
Discovered via Geyser + Floodgate. When Floodgate's websocket subscriber fails to connect to the Global API (e.g. temporary unavailability during startup), Geyser's FloodgateSkinUploader falls back to sending skin data via plugin message on the floodgate:skin channel. If the Global API responds quickly (cached skin), this message arrives during the null window and is silently dropped. The player appears as default skins to Java players.
Velocity Version
[18:08:06 INFO]: Velocity 3.5.0-SNAPSHOT (git-ab99bde9-b585)
[18:08:06 INFO]: Copyright 2018-2026 Velocity Contributors. Velocity is licensed under the terms of the GNU General Public License v3.
[18:08:06 INFO]: PaperMC - GitHub
Additional Information
Proposed fix: Remove the Forge channel gate on the getConnectionInFlight() fallback:
VelocityServerConnection serverConn = player.getConnectedServer();
if (serverConn == null) {
serverConn = player.getConnectionInFlight();
}
This is safe because the downstream code already guards against edge cases:
- Backend state check (line 311) rejects writes if the backend isn't in PLAY
- Phase completeness checks (lines 352-353) queue messages if phases aren't complete
- The in-flight connection's backend is already in
StateRegistry.PLAY (installed via LoginSessionHandler line 158 / ConfigSessionHandler line 252)
Expected Behavior
Plugin messages sent on registered channels during the player join process should be forwarded to the backend server via
getConnectionInFlight()whengetConnectedServer()is null, the same way Forge Legacy handshake messages are. Messages should not be silently discarded.Actual Behavior
ClientPlaySessionHandler.handle(PluginMessagePacket)at lines 303 to 310 only falls back togetConnectionInFlight()when the channel isLegacyForgeConstants.FORGE_LEGACY_HANDSHAKE_CHANNEL. For all other channels, whengetConnectedServer()returns null during the join window,serverConnis set to null and the handler silently skips the message. No warning, no error, no exception, etc.The null window exists between
player.setConnectedServer(null)atTransitionSessionHandlerline 100 andplayer.setConnectedServer(serverConn)at line 140 (inside athenRunAsynccallback). During this window the client connection continues processing packets, but any non-Forge plugin message is dropped.This is the same null window documented in issue #569, where
ServerConnectedEventis described as "fatally flawed" becausePlayer#getCurrentServer()returns null during it.Steps to Reproduce
getConnectedServer()is still null (e.g. a fast async callback such as a cached response completing during the join window)The bug is intermittent. It only triggers when the message arrives during the null window between
setConnectedServer(null)andsetConnectedServer(serverConn). A delayed retry reliably succeeds.Plugin List
N/A - reproducible with any plugin that sends messages on a custom channel during the join process.
Discovered via Geyser + Floodgate. When Floodgate's websocket subscriber fails to connect to the Global API (e.g. temporary unavailability during startup), Geyser's
FloodgateSkinUploaderfalls back to sending skin data via plugin message on thefloodgate:skinchannel. If the Global API responds quickly (cached skin), this message arrives during the null window and is silently dropped. The player appears as default skins to Java players.Velocity Version
[18:08:06 INFO]: Velocity 3.5.0-SNAPSHOT (git-ab99bde9-b585)
[18:08:06 INFO]: Copyright 2018-2026 Velocity Contributors. Velocity is licensed under the terms of the GNU General Public License v3.
[18:08:06 INFO]: PaperMC - GitHub
Additional Information
Proposed fix: Remove the Forge channel gate on the
getConnectionInFlight()fallback:This is safe because the downstream code already guards against edge cases:
StateRegistry.PLAY(installed viaLoginSessionHandlerline 158 /ConfigSessionHandlerline 252)