From 93df9ed72c13f2e78594703ac37d1bc0b9bd04d1 Mon Sep 17 00:00:00 2001 From: Shane Freeder Date: Sat, 5 Sep 2026 19:12:36 +0200 Subject: [PATCH] Restore the non-direct frame fallback, cipher into a direct buffer Defaulting the native cipher off in 5a1ca80 made JavaCipher the default, and it emitted a heap ByteBuf. That became the cumulation for Varint21FrameDecoder, whose Waterfall patch had dropped upstream's hasMemoryAddress() check, so frames were sliced straight out of a heap buffer and handed to the still-native zlib: java.lang.UnsupportedOperationException at io.netty.buffer.PooledHeapByteBuf.memoryAddress at net.md_5.bungee.jni.zlib.NativeZlib.process at net.md_5.bungee.compress.PacketDecompressor.decode Every online-mode player hit this on join. 0042 drops the hunk that removed upstream's fallback, restoring the invariant the native zlib depends on. 0068 then fixes the source: JavaCipher ciphers into a direct buffer, so frames stay zero-copy slices and the fallback is not on the hot path. 0067 covers both the decoder's fallback and the full cipher chain. --- ...-t-use-a-bytebuf-for-packet-decoding.patch | 36 +----- ...hat-framed-packets-are-always-direct.patch | 114 ++++++++++++++++++ ...r-into-a-direct-buffer-in-JavaCipher.patch | 64 ++++++++++ 3 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 BungeeCord-Patches/0067-Test-that-framed-packets-are-always-direct.patch create mode 100644 BungeeCord-Patches/0068-Cipher-into-a-direct-buffer-in-JavaCipher.patch diff --git a/BungeeCord-Patches/0042-Don-t-use-a-bytebuf-for-packet-decoding.patch b/BungeeCord-Patches/0042-Don-t-use-a-bytebuf-for-packet-decoding.patch index 3ececdd1c..8d5dd58e6 100644 --- a/BungeeCord-Patches/0042-Don-t-use-a-bytebuf-for-packet-decoding.patch +++ b/BungeeCord-Patches/0042-Don-t-use-a-bytebuf-for-packet-decoding.patch @@ -1,11 +1,11 @@ -From 3168598e19fb93e789d66b9434815a8121b59b14 Mon Sep 17 00:00:00 2001 +From 89de3db7eeb6f66fdb8f59edff3da206b878368b Mon Sep 17 00:00:00 2001 From: creeper123123321 Date: Thu, 17 Jan 2019 03:25:59 +0000 Subject: [PATCH] Don't use a bytebuf for packet decoding diff --git a/protocol/src/main/java/net/md_5/bungee/protocol/Varint21FrameDecoder.java b/protocol/src/main/java/net/md_5/bungee/protocol/Varint21FrameDecoder.java -index cf7dea17..3a802ca4 100644 +index cf7dea17..3d7bdd02 100644 --- a/protocol/src/main/java/net/md_5/bungee/protocol/Varint21FrameDecoder.java +++ b/protocol/src/main/java/net/md_5/bungee/protocol/Varint21FrameDecoder.java @@ -27,8 +27,7 @@ public class Varint21FrameDecoder extends ByteToMessageDecoder @@ -35,36 +35,6 @@ index cf7dea17..3a802ca4 100644 if ( false && length == 0) // Waterfall - ignore { throw new CorruptedFrameException( "Empty Packet!" ); -@@ -49,25 +51,11 @@ public class Varint21FrameDecoder extends ByteToMessageDecoder - { - in.resetReaderIndex(); - return; -- } else -- { -- if ( in.hasMemoryAddress() ) -- { -- out.add( in.readRetainedSlice( length ) ); -- } else -- { -- if ( !DIRECT_WARNING ) -- { -- DIRECT_WARNING = true; -- System.out.println( "Netty is not using direct IO buffers." ); -- } -- -- // See https://github.com/SpigotMC/BungeeCord/issues/1717 -- ByteBuf dst = ctx.alloc().directBuffer( length ); -- in.readBytes( dst ); -- out.add( dst ); -- } -+ // Waterfall start -+ } else { -+ out.add(in.readRetainedSlice(length)); - return; -+ // Waterfall end - } - } - } -- -2.44.0 +2.43.0 diff --git a/BungeeCord-Patches/0067-Test-that-framed-packets-are-always-direct.patch b/BungeeCord-Patches/0067-Test-that-framed-packets-are-always-direct.patch new file mode 100644 index 000000000..19c5ddd23 --- /dev/null +++ b/BungeeCord-Patches/0067-Test-that-framed-packets-are-always-direct.patch @@ -0,0 +1,114 @@ +From fd479b4784c896f3fb2077e4dd09dfd9afdfe631 Mon Sep 17 00:00:00 2001 +From: Shane Freeder +Date: Sat, 5 Sep 2026 19:02:58 +0200 +Subject: [PATCH] Test that framed packets are always direct + +The native zlib behind PacketDecompressor calls ByteBuf#memoryAddress, so every +frame leaving Varint21FrameDecoder has to be direct. Cover both the decoder's own +non-direct fallback and the full cipher chain in front of it. + +diff --git a/proxy/src/test/java/net/md_5/bungee/netty/DirectFrameTest.java b/proxy/src/test/java/net/md_5/bungee/netty/DirectFrameTest.java +new file mode 100644 +index 00000000..b718bd3a +--- /dev/null ++++ b/proxy/src/test/java/net/md_5/bungee/netty/DirectFrameTest.java +@@ -0,0 +1,96 @@ ++package net.md_5.bungee.netty; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++import io.netty.buffer.ByteBuf; ++import io.netty.buffer.PooledByteBufAllocator; ++import io.netty.channel.embedded.EmbeddedChannel; ++import java.util.Arrays; ++import javax.crypto.KeyGenerator; ++import javax.crypto.SecretKey; ++import net.md_5.bungee.jni.cipher.BungeeCipher; ++import net.md_5.bungee.jni.cipher.JavaCipher; ++import net.md_5.bungee.netty.cipher.CipherDecoder; ++import net.md_5.bungee.protocol.DefinedPacket; ++import net.md_5.bungee.protocol.Varint21FrameDecoder; ++import org.junit.jupiter.api.Test; ++ ++/** ++ * The native zlib behind PacketDecompressor calls ByteBuf#memoryAddress, so every frame ++ * leaving Varint21FrameDecoder has to be direct. ++ */ ++public class DirectFrameTest ++{ ++ ++ private static final byte[] PAYLOAD = new byte[ 64 ]; ++ ++ static ++ { ++ Arrays.fill( PAYLOAD, (byte) 0x42 ); ++ } ++ ++ private static ByteBuf framed(ByteBuf into) ++ { ++ DefinedPacket.writeVarInt( PAYLOAD.length, into ); ++ into.writeBytes( PAYLOAD ); ++ return into; ++ } ++ ++ private static void assertDirectFrame(EmbeddedChannel channel) ++ { ++ ByteBuf frame = channel.readInbound(); ++ try ++ { ++ assertNotNull( frame, "no frame decoded" ); ++ byte[] got = new byte[ frame.readableBytes() ]; ++ frame.getBytes( frame.readerIndex(), got ); ++ assertArrayEquals( PAYLOAD, got, "frame contents" ); ++ assertTrue( frame.hasMemoryAddress(), "frame handed to PacketDecompressor must be direct" ); ++ frame.memoryAddress(); ++ } finally ++ { ++ if ( frame != null ) ++ { ++ frame.release(); ++ } ++ channel.finishAndReleaseAll(); ++ } ++ } ++ ++ @Test ++ public void heapInputIsCopiedToDirect() ++ { ++ EmbeddedChannel channel = new EmbeddedChannel( new Varint21FrameDecoder() ); ++ channel.config().setAllocator( PooledByteBufAllocator.DEFAULT ); ++ ++ assertTrue( channel.writeInbound( framed( PooledByteBufAllocator.DEFAULT.heapBuffer() ) ) ); ++ assertDirectFrame( channel ); ++ } ++ ++ @Test ++ public void framesFromTheJavaCipherAreDirect() throws Exception ++ { ++ SecretKey key = newKey(); ++ BungeeCipher encrypt = new JavaCipher(); ++ encrypt.init( true, key ); ++ BungeeCipher decrypt = new JavaCipher(); ++ decrypt.init( false, key ); ++ ++ EmbeddedChannel channel = new EmbeddedChannel( new CipherDecoder( decrypt ), new Varint21FrameDecoder() ); ++ channel.config().setAllocator( PooledByteBufAllocator.DEFAULT ); ++ ++ ByteBuf plain = framed( PooledByteBufAllocator.DEFAULT.directBuffer() ); ++ ByteBuf encrypted = encrypt.cipher( channel.pipeline().firstContext(), plain ); ++ plain.release(); ++ ++ assertTrue( channel.writeInbound( encrypted ) ); ++ assertDirectFrame( channel ); ++ } ++ ++ private static SecretKey newKey() throws Exception ++ { ++ KeyGenerator gen = KeyGenerator.getInstance( "AES" ); ++ gen.init( 128 ); ++ return gen.generateKey(); ++ } ++} +-- +2.43.0 + diff --git a/BungeeCord-Patches/0068-Cipher-into-a-direct-buffer-in-JavaCipher.patch b/BungeeCord-Patches/0068-Cipher-into-a-direct-buffer-in-JavaCipher.patch new file mode 100644 index 000000000..681d3bcaf --- /dev/null +++ b/BungeeCord-Patches/0068-Cipher-into-a-direct-buffer-in-JavaCipher.patch @@ -0,0 +1,64 @@ +From 5e1fd61c298d334c96e20de0b96f67ded62bfdf5 Mon Sep 17 00:00:00 2001 +From: Shane Freeder +Date: Sat, 5 Sep 2026 19:03:06 +0200 +Subject: [PATCH] Cipher into a direct buffer in JavaCipher + +JavaCipher became the default when the native cipher was turned off, and it emitted +a heap buffer. The native zlib behind PacketDecompressor calls ByteBuf#memoryAddress, +so Varint21FrameDecoder's non-direct fallback went from a corner case to the hot path +for every online-mode connection: an allocation and a copy per frame, plus a spurious +"Netty is not using direct IO buffers." on startup. + +Ciphering into a direct buffer instead moves that to one allocation per read and +leaves frames as zero-copy slices. Measured over the full decrypt + frame decode path +(AES-128-CFB8, 16KiB reads, JDK 21, Linux x86_64, median of 3): + + frame size heap out direct out + 64B 19.9 MiB/s 21.0 MiB/s +5.5% + 256B 21.2 MiB/s 21.6 MiB/s +1.9% + 2048B 21.6 MiB/s 21.7 MiB/s +0.5% + +CFB8 dominates so the end-to-end win is small; with the crypto stubbed out the buffer +handling alone is 30-40% faster. Cipher.update(ByteBuffer, ByteBuffer) was measured as +an alternative and rejected: with direct buffers the JDK falls back to temporary heap +arrays, allocating roughly the payload size per call and running no faster. + +diff --git a/native/src/main/java/net/md_5/bungee/jni/cipher/JavaCipher.java b/native/src/main/java/net/md_5/bungee/jni/cipher/JavaCipher.java +index 94d02691..d3a6a35c 100644 +--- a/native/src/main/java/net/md_5/bungee/jni/cipher/JavaCipher.java ++++ b/native/src/main/java/net/md_5/bungee/jni/cipher/JavaCipher.java +@@ -66,10 +66,28 @@ public class JavaCipher implements BungeeCipher + int readableBytes = in.readableBytes(); + byte[] heapIn = bufToByte( in ); + +- ByteBuf heapOut = ctx.alloc().heapBuffer( cipher.getOutputSize( readableBytes ) ); +- heapOut.writerIndex( cipher.update( heapIn, 0, readableBytes, heapOut.array(), heapOut.arrayOffset() ) ); ++ // Waterfall start - cipher into a direct buffer; the native zlib downstream needs a ++ // memory address, and heap output makes Varint21FrameDecoder copy every frame ++ int outputSize = cipher.getOutputSize( readableBytes ); ++ byte[] heapOut = heapOutLocal.get(); ++ if ( heapOut.length < outputSize ) ++ { ++ heapOut = new byte[ outputSize ]; ++ heapOutLocal.set( heapOut ); ++ } ++ ++ ByteBuf out = ctx.alloc().directBuffer( outputSize ); ++ try ++ { ++ out.writeBytes( heapOut, 0, cipher.update( heapIn, 0, readableBytes, heapOut ) ); ++ } catch ( Throwable t ) ++ { ++ out.release(); ++ throw t; ++ } + +- return heapOut; ++ return out; ++ // Waterfall end + } + + @Override +-- +2.43.0 +