Skip to content

fix: stop random login kicks and duplicated quit messages (1.21.11) - #596

Open
KostiaFed wants to merge 2 commits into
CardboardPowered:ver/1.21.11from
KostiaFed:fix/duplicate-login-and-disconnect-1.21.11
Open

fix: stop random login kicks and duplicated quit messages (1.21.11)#596
KostiaFed wants to merge 2 commits into
CardboardPowered:ver/1.21.11from
KostiaFed:fix/duplicate-login-and-disconnect-1.21.11

Conversation

@KostiaFed

Copy link
Copy Markdown
Contributor

The problem

Two independent bugs in the connection lifecycle. Both are timing dependent, which is why they look like random server flakiness rather than a reproducible fault.

Logging in runs the client verification twice (offline mode). In offline mode vanilla handleHello() calls startClientVerification() itself, and ServerLoginPacketListenerImplMixin injected at its TAIL to start the Bukkit pre-login thread — which calls startClientVerification() a second time once AsyncPlayerPreLoginEvent has run. So the login state machine is driven twice, from two threads:

handleHello    -> startClientVerification -> state = VERIFYING
tick           -> verifyLoginAndFinishConnectionSetup
               -> finishLoginAndWaitForClient
               -> state = PROTOCOL_SWITCHING, sends ClientboundLoginFinishedPacket
authenticator  -> startClientVerification -> state = VERIFYING again
tick           -> verifyLoginAndFinishConnectionSetup a second time
               -> a second ClientboundLoginFinishedPacket

Which of the three possible outcomes a player gets is decided purely by where the tick lands, so joining takes several attempts:

[16:07:24] Stas (/x.x.x.x:52351) lost connection: Internal Exception:
  io.netty.handler.codec.EncoderException: Pipeline has no outbound protocol
  configured, can't process packet class_2901[gameProfile=GameProfile[
  id=7d176c15-c7dc-3c1c-ac94-b5c51390dfc1, name=Stas, properties={}]]
[16:07:31] Stas (/x.x.x.x:59674) lost connection: Internal Exception:
  java.lang.IllegalStateException: Unexpected login acknowledgement packet
[16:08:07] Stas joined the game

class_2901 is ClientboundLoginFinishedPacket: the second copy is written after the client has already acknowledged the first and the pipeline has left the login protocol. The other kick is the mirror image — the acknowledgement arrives while the state has just been thrown back to VERIFYING, so handleLoginAcknowledgement's Validate.validState(state == PROTOCOL_SWITCHING) fails.

Disconnecting runs the quit handling twice. ServerGamePacketListenerImplMixin overwrites disconnect(Component) to fire PlayerKickEvent. It then calls onDisconnect() directly so the quit event fires without waiting, and afterwards schedules Connection.handleDisconnection(), which calls onDisconnect() again. CraftBukkit guards both with a processedDisconnect flag; here it only existed inside commented-out code, so a single keep-alive timeout or kick ran the whole quit path twice:

[17:49:15] Stas lost connection: Timed out
[17:49:15] Stas left the game
[17:49:15] [voicechat] Disconnecting client Stas
[17:49:15] Stas lost connection: Timed out
[17:49:15] Stas left the game
[17:49:15] [voicechat] Disconnecting client Stas
[17:49:15] [WARN]: handleDisconnection() called twice

Connection's own guard is what produces that last warning, and it only trips after both rounds have already run. Plugins see PlayerQuitEvent twice, so in game this shows up as duplicated leave messages.

The fix

  • Redirect the offline branch's startClientVerification() call rather than injecting after it, so vanilla's call is replaced instead of duplicated. The pre-login events still run off the main thread, and the state is parked in AUTHENTICATING while they do — exactly what the online path does. initUUID() now takes and returns the profile instead of mutating authenticatedProfile, which is not set yet at that point, and the thread comes from the existing authenticator pool instead of a fresh unbounded Thread per connection.
  • Add the processedDisconnect flag: disconnect() returns early if the disconnect was already processed, onDisconnect() is cancelled at HEAD on the second call, and isDisconnected() reports it, matching CraftBukkit.

The @At ordinal = 1 picks the offline-mode call site; ordinal 0 is the integrated-server owner shortcut. Verified against the bytecode of both 1.21.11 and 26.1.2 — the two call sites are in the same order in each.

Testing

gradlew build passes on both ver/1.21.11 and ver/26.1.

The diagnosis is from the server log above plus the vanilla bytecode: handleHello's offline branch calling startClientVerification at the instruction the old TAIL inject ran after, tick() re-entering verifyLoginAndFinishConnectionSetup on VERIFYING, Connection.handleDisconnection logging handleDisconnection() called twice only after dispatching onDisconnect, and the intermediary mapping identifying class_2901 as ClientboundLoginFinishedPacket.

Not covered: mixin application and the in-game behaviour are not exercised here, since both bugs need a real client connecting and timing out. Worth a look from anyone who can reproduce the original kicks.

KostiaFed and others added 2 commits August 15, 2026 18:31
In offline mode vanilla handleHello() calls startClientVerification() itself, and
ServerLoginPacketListenerImplMixin injected at its TAIL to start the Bukkit
pre-login thread - which calls startClientVerification() a second time once the
events have run. So the login state machine was driven twice from two threads:

  handleHello       -> startClientVerification -> state = VERIFYING
  tick              -> verifyLoginAndFinishConnectionSetup
                    -> finishLoginAndWaitForClient
                    -> state = PROTOCOL_SWITCHING, sends ClientboundLoginFinished
  authenticator     -> startClientVerification -> state = VERIFYING again
  tick              -> verifyLoginAndFinishConnectionSetup a second time
                    -> a second ClientboundLoginFinishedPacket

Which of the three outcomes a player got depended purely on timing, so joining
took several attempts:

  lost connection: Internal Exception: io.netty.handler.codec.EncoderException:
    Pipeline has no outbound protocol configured, can't process packet
    class_2901[gameProfile=GameProfile[id=..., name=Stas]]
  lost connection: Internal Exception: java.lang.IllegalStateException:
    Unexpected login acknowledgement packet

class_2901 is ClientboundLoginFinishedPacket: the second copy was written after
the client had already acknowledged the first and the pipeline had left the login
protocol. The other kick is the mirror image - the acknowledgement arrived while
the state had just been thrown back to VERIFYING.

Redirect the offline branch's startClientVerification() instead of injecting
after it, so vanilla's call is replaced rather than duplicated. The pre-login
events still run off the main thread, and the state is parked in AUTHENTICATING
while they do, exactly as the online path does. initUUID() takes and returns the
profile rather than mutating authenticatedProfile, which is not set yet at that
point, and the thread comes from the existing authenticator pool instead of a
fresh unbounded Thread per connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ServerGamePacketListenerImplMixin overwrites disconnect(Component) to fire
PlayerKickEvent. It then calls onDisconnect() directly so the quit event fires
without waiting, and afterwards schedules Connection.handleDisconnection(), which
calls onDisconnect() again. CraftBukkit guards both with a processedDisconnect
flag; Cardboard only had it in commented-out code, so a single keep-alive timeout
or kick ran the whole quit path twice:

  Stas lost connection: Timed out
  Stas left the game
  [voicechat] Disconnecting client Stas
  Stas lost connection: Timed out
  Stas left the game
  [voicechat] Disconnecting client Stas
  handleDisconnection() called twice

Connection's own guard is what produces that last warning, and it only fires
after both rounds have already run. Plugins saw PlayerQuitEvent twice, so in game
this showed up as duplicated leave messages.

Add the flag: disconnect() returns early if the disconnect was already processed,
onDisconnect() is cancelled at HEAD on the second call, and isDisconnected()
reports it, matching CraftBukkit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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