Skip to content

Prevent TailExceptionsHandler from racing a graceful closeWith() close#616

Open
purpurcof wants to merge 1 commit into
jonesdevelopment:mainfrom
purpurcof:fix/tail-exception-handler-double-close
Open

Prevent TailExceptionsHandler from racing a graceful closeWith() close#616
purpurcof wants to merge 1 commit into
jonesdevelopment:mainfrom
purpurcof:fix/tail-exception-handler-double-close

Conversation

@purpurcof

Copy link
Copy Markdown

How do I reproduce this?

Hard to reproduce reliably against a live server/client - it depends on the kick packet's write not completing synchronously, which is more likely under load (many concurrent connections, e.g. via SoulFire bot floods, per #476). Instead, I isolated the exact mechanism with a standalone repro on plain sockets (no Netty/Sonar dependencies needed): RaceTest.java (attached). It simulates an async write-then-close racing against an immediate, independent second close() call while the write is still in flight - the same pattern as VerificationHandler#fail() calling disconnect() and then throwing.

Every run: the write fails with SocketException: Socket closed, and the client receives only a truncated fragment of the payload instead of the full message - reproducing the "client gets a bare reset instead of the kick message" symptom from #476.

I also tested and ruled out the alternative theory that this is caused by a TCP RST from unread data in the receive buffer at close() time (RstTest.java, attached) - that does not reproduce here even with 2MB of unread data, with a SO_LINGER(0) sanity control confirming the harness can detect RST when it actually occurs.

Root cause

VerificationHandler#fail() calls user.disconnect(...), which queues an async channel.writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE) in ProtocolUtil#closeWith(), and then immediately throws QuietDecoderException.INSTANCE to unwind. That exception propagates through SonarPacketDecoder#channelRead() and reaches TailExceptionsHandler#exceptionCaught(), which calls ctx.close() unconditionally - a second, independent close racing the one already queued. If the kick packet write hasn't finished flushing yet, this second close aborts it, so the client only gets a truncated packet instead of the full kick message.

#595 attempted to fix this by catching QuietDecoderException in the decoder, but QuietDecoderException.INSTANCE is also thrown from ~15 other places (MinecraftVarInt21FrameDecoder, LoginStartPacket, SystemChatPacket, PluginMessagePacket, etc.) that never call disconnect() first and rely entirely on reaching TailExceptionsHandler to close the connection at all. Catching it in the decoder would silently leave those connections open indefinitely - a resource leak / DoS surface for an antibot plugin.

Fix

Tag the channel with a CLOSING attribute right when closeWith() queues its write, and have TailExceptionsHandler skip its own close() only when that attribute is set - every other QuietDecoderException throw site keeps closing the connection exactly as before.

Verification

Reproduced the race directly with RaceTest.java (attached, 5/5 runs). Have not been able to verify against a live server + SoulFire per the repro steps in #476 - would appreciate a maintainer or the original reporter confirming against a real setup.

References

@jonesdevelopment

Copy link
Copy Markdown
Owner

That actually makes a lot of sense. I'll take a look at it once I'm home.

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.

Bug Report: Connection reset instead of kick message

2 participants