Skip to content

net: fix logins lost when FML handshakes finish at the same time - #214

Merged
EverNife merged 3 commits into
stagingfrom
fix/fml-handshake-concurrent-logins
Oct 18, 2026
Merged

EverNife merged 3 commits into
stagingfrom
fix/fml-handshake-concurrent-logins

Conversation

@EverNife

Copy link
Copy Markdown
Member

Problem

When several modded clients log in at the same moment, some of them finish the FML handshake on both sides (the client receives HandshakeAck {3}, which the server only sends when it reaches COMPLETE), but the server never logs logged in with entity id. The player sits in limbo until the timeout. With 10 clients connecting at once, 2 to 7 were lost in every round; with 30 at once, 8 to 11.

All connections share one server-side FML EmbeddedChannel (FMLNetworkHandler.channelPair). A handshake completes in two steps, on two different threads:

  1. Netty thread of the connection: forwardHandshake stores the connection's dispatcher in the shared channel's FML_DISPATCHER attribute, writes the CompleteHandshake, and drains the shared outbound queue into the connection.
  2. Main thread, on a later tick: the drained packet is processed and HandshakeCompletionHandler reads the dispatcher back from the shared attribute.

Nothing was locked, and the attribute was read long after it was written. Two logins finishing together could:

  • swap dispatchers. The attribute holds whichever login wrote it last, so one login completes the other's connection, and the second read finds null. The Cauldron null check then drops that completion silently.
  • drain each other's packet. One thread carries the other connection's CompleteHandshake along with its own, and the same packet object can be processed twice. That shows up as FMLMessage$OpenGui.fromBytes IndexOutOfBoundsException: the packet is [0, 1] (discriminator, Side.SERVER), so the second read takes the 1 as the OpenGui discriminator, with an empty body.

A second, separate race hit the same bursts. Async chat broadcasts over ServerConfigurationManager.playerEntityList, a plain ArrayList, on the sender's Netty thread, while the main thread adds the players who are logging in. The sender was kicked with Internal Exception: java.util.ConcurrentModificationException:

java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.next(ArrayList.java:861)
	at net.minecraft.network.NetHandlerPlayServer.chat(NetHandlerPlayServer.java:1314)
	at net.minecraft.network.NetHandlerPlayServer.func_147354_a(NetHandlerPlayServer.java:1167)
	at net.minecraft.network.play.client.C01PacketChatMessage.func_148833_a(C01PacketChatMessage.java:38)
	at net.minecraft.network.NetworkManager.channelRead0(NetworkManager.java:142)

Change

  • FMLNetworkHandler.forwardHandshake does "set attribute, write, drain, clear" inside synchronized on the shared channel, and removes the attribute in a finally. If the attribute were left set, FMLOutboundHandler would take every later FML message (OpenGui, entity spawn) for a handshake-internal one and divert it to the queue instead of the player.

  • openGui, makeEntitySpawnAdjustment, getEntitySpawningPacket are the other writers of that channel (main thread). They take the same lock, so none of them can interleave with a handshake drain.

  • HandshakeCompletionHandler no longer reads the shared attribute. It resolves the dispatcher from the connection the packet arrived on: FMLProxyPacket.processPacket sets NetworkRegistry.NET_HANDLER right before the writeInbound, which gives NetHandlerPlayServer → netManager → NetworkDispatcher.get. The client side, with a single connection, uses getClientToServerNetworkManager(). On the server, a completion that arrives without a player's play handler logs a warning instead of falling into the client-only lookup, which throws there. A null dispatcher now only means the connection closed while the packet waited in the queue.

  • playerEntityList becomes a SnapshotIteratingArrayList, a new ArrayList subclass whose iterators (iterator, listIterator, forEach, spliterator/stream) walk a copy taken when they start. Iterating never throws ConcurrentModificationException, so the async chat loops (the broadcast, the @name ping loop and LazyPlayerSet) are safe while players join or leave. So are plugins that call Bukkit.getOnlinePlayers() off the main thread, since that is a live view of this list. Writes are unchanged and stay on the main thread. Iterator.remove still removes from the live list; ListIterator.set/add throw, with a message that points to List.set/List.add.

    It stays an ArrayList on purpose. A CopyOnWriteArrayList (what CraftBukkit, Magma and Mohist use) breaks mods that cast the field. WarpTheory GTNH up to 1.5.1 does (ArrayList) playerEntityList, and GTNH 2.8.x ships WarpTheory 1.5.0. A bytecode scan of 509 mod and plugin jars found that cast, and no ListIterator use or reflective replacement of the list.

Testing

Raw 1.7.10 FML clients (minecraft-protocol answering FML|HS with an empty mod list), all started at the same instant in their own containers, against a server built from staging with and without this change. The server runs only its loader mods.

Simultaneous logins (each client chats once, 1.5 s after joining):

before (2 runs) after (2 runs)
5 rounds × 10 logins 33/50, 37/50 50/50, 50/50
1 round × 30 logins 19/30, 22/30 30/30, 30/30
players stuck after HandshakeAck {3} 28, 21 0, 0
OpenGui.fromBytes IOOBE 3, 0 0, 0

Chat during a login burst (3 rounds × 30 logins, every client chatting every 400 ms; the "before" build already has the handshake fix):

before after
players joined 90/90 90/90
chat messages broadcast 1189 1261
kicked with ConcurrentModificationException 9 0

SnapshotIteratingArrayList stress check, on Java 8 and 25: one writer adding and removing while 4 readers run for-each, forEach and stream() for 3 s. About 7 million full reads, no exception and no null element. A plain ArrayList under the same load throws within the run. The check also covers the cast to ArrayList, removing during a for-each, and Iterator.remove.

Every connection shares the one FML embedded channel. Logins finishing
at once crossed its dispatcher attribute and drained each other's
packets, leaving players stuck after the handshake.
A completion that reaches the server without a player's play handler
used to fall through to the client-only lookup, which throws there.
Async chat broadcasts over playerEntityList on the sender's Netty
thread while logins add to it, and the sender was kicked with a
ConcurrentModificationException. The list stays an ArrayList because
mods cast it to one.
@EverNife
EverNife merged commit 25a589c into staging Oct 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant