Repository navigation
Server WebSocket: a frame pipelined with the upgrade request is dropped and its buffer leaks (regression from 4.5) #1
Description
Version
vertx-core 5.2.2 (also 5.1.8). The code is unchanged on master. Works in 4.5.35.
Context
A client sends a WebSocket frame in the same TCP write as the HTTP upgrade request, before it has seen the 101.
On 5.x the server never delivers that frame to the ServerWebSocket, and the frame's ByteBuf is never released.
RFC 6455 4.1 says a client must wait for the 101, so a conforming client never does this.
Losing the frame is therefore a minor regression.
The leak is the real problem, because whoever controls the client chooses the frame's size and how often it is sent.
A server that accepts WebSockets from untrusted clients leaks pooled buffer memory each time.
We hit it in a MITM proxy whose clients are semi-trusted sandboxed workloads, where one misbehaving client could exhaust the proxy's direct memory for everyone.
What happens
Netty's leak detector (-Dio.netty.leakDetection.level=paranoid) gives the path.
During accept(), Netty's WebSocketServerHandshaker.handshake0 replaces the HTTP decoder with the WebSocket decoder.
ByteToMessageDecoder.handlerRemoved hands its leftover bytes, our pipelined frame, to the new decoder.
That fires a decoded WebSocketFrame down the pipeline straight away.
At that point the "handler" in the pipeline is still the HTTP connection's VertxHandler.
ServerWebSocketHandshaker.acceptHandshake replaces it only later, in the listener of the 101 write's promise (pipeline.replace(VertxHandler.class, "handler", handler), ServerWebSocketHandshaker.java:213 on master).
So the frame reaches Http1ServerConnection.handleMessage → handleOther:
private void handleOther(Object msg) {
// concrete type check first
if (msg instanceof DefaultHttpContent || msg instanceof HttpContent) {
onContent(msg);
}
}That drops anything that is not HttpContent without ReferenceCountUtil.release(msg).
So the frame is lost, and so is its buffer.
The leak record, trimmed:
LEAK: ByteBuf.release() was not called before it's garbage-collected.
#1:
Hint: 'handler' will handle the message from this point.
io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame.touch(BinaryWebSocketFrame.java:24)
io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:344)
io.netty.handler.codec.http.websocketx.extensions.WebSocketServerExtensionHandler.channelRead(WebSocketServerExtensionHandler.java:102)
io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:361)
io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:325)
io.netty.handler.codec.ByteToMessageDecoder.handlerRemoved(ByteToMessageDecoder.java:270)
io.netty.channel.DefaultChannelPipeline.replace(DefaultChannelPipeline.java:497)
io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker.handshake0(WebSocketServerHandshaker.java:283)
io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker.handshake(WebSocketServerHandshaker.java:205)
io.vertx.core.http.impl.websocket.ServerWebSocketHandshaker.acceptHandshake(ServerWebSocketHandshaker.java:172)
io.vertx.core.http.impl.websocket.ServerWebSocketHandshaker.accept(ServerWebSocketHandshaker.java:105)
...
In 4.5.35 a webSocketHandler that pauses the socket receives the frame once it resumes.
Steps to reproduce
Two single-file programs. Each depends only on io.vertx:vertx-core, and both run with the JDK's source launcher:
# CP: vertx-core 5.2.2's runtime classpath, e.g. from `mvn dependency:build-classpath` on a pom depending on it
java -Dio.netty.leakDetection.level=paranoid -cp "$CP" PipelinedFrameDropped.java
java -Dio.netty.leakDetection.level=paranoid -cp "$CP" PipelinedFrameLeaks.java 2>&1 | grep -c LEAK:1. The frame is lost.
An echo server, with a pausing webSocketHandler (the default mode) or webSocketHandshakeHandler + accept() (handshake).
It sends a text frame in the same write as the upgrade request, then sends one after the 101 as a control.
import io.vertx.core.Vertx;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
public class PipelinedFrameDropped {
static final String SAMPLE_KEY = java.util.Base64.getEncoder().encodeToString("the sample nonce".getBytes());
public static void main(String[] a) throws Exception {
String mode = a.length > 0 ? a[0] : "ws-pause"; // ws-pause | handshake
Vertx vertx = Vertx.vertx();
var server = vertx.createHttpServer();
if (mode.equals("handshake")) {
server.webSocketHandshakeHandler(hs -> hs.accept().onSuccess(ws ->
ws.textMessageHandler(m -> ws.writeTextMessage("echo:" + m))));
} else {
server.webSocketHandler(ws -> { ws.pause(); ws.textMessageHandler(m -> ws.writeTextMessage("echo:" + m)); vertx.setTimer(50, t -> ws.resume()); });
}
int port = server.listen(0).toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS).actualPort();
for (String send : new String[]{"pipelined", "after101"}) {
try (var s = new Socket("127.0.0.1", port)) {
s.setSoTimeout(2000);
var req = ("GET /x HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+ "Sec-WebSocket-Key: " + SAMPLE_KEY + "\r\nSec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.US_ASCII);
byte[] p = "hi".getBytes(); byte[] m = {1,2,3,4};
var f = new ByteArrayOutputStream(); f.write(0x81); f.write(0x80 | p.length); f.write(m);
for (int i = 0; i < p.length; i++) f.write(p[i] ^ m[i % 4]);
var out = s.getOutputStream(); var in = s.getInputStream();
if (send.equals("pipelined")) { var all = new ByteArrayOutputStream(); all.write(req); all.write(f.toByteArray()); out.write(all.toByteArray()); }
else out.write(req);
out.flush();
var head = new StringBuilder();
while (!head.toString().endsWith("\r\n\r\n")) { int b = in.read(); if (b < 0) break; head.append((char) b); }
if (send.equals("after101")) { out.write(f.toByteArray()); out.flush(); }
String result;
try { in.read(); int len = in.read() & 0x7f; result = new String(in.readNBytes(len)); }
catch (java.net.SocketTimeoutException e) { result = "TIMEOUT (frame lost)"; }
System.out.println(Vertx.class.getPackage().getImplementationVersion() + " mode=" + mode + " " + send + ": " + head.toString().split("\r\n")[0] + " -> " + result);
}
}
vertx.close();
}
}Output:
5.2.2 mode=ws-pause pipelined: HTTP/1.1 101 Switching Protocols -> TIMEOUT (frame lost)
5.2.2 mode=ws-pause after101: HTTP/1.1 101 Switching Protocols -> echo:hi
4.5.35 mode=ws-pause pipelined: HTTP/1.1 101 Switching Protocols -> echo:hi
4.5.35 mode=ws-pause after101: HTTP/1.1 101 Switching Protocols -> echo:hi
The handshake mode loses the frame too on 5.2.2.
On 4.5 that mode is not comparable, because it also needs a webSocketHandler there.
2. The buffer leaks.
300 upgrades, each with a 100-byte binary frame pipelined behind it:
import io.vertx.core.Vertx;
import java.io.*;
import java.net.Socket;
import java.util.concurrent.TimeUnit;
public class PipelinedFrameLeaks {
static final String SAMPLE_KEY = java.util.Base64.getEncoder().encodeToString("the sample nonce".getBytes());
public static void main(String[] a) throws Exception {
Vertx vertx = Vertx.vertx();
var server = vertx.createHttpServer().webSocketHandler(ws -> ws.textMessageHandler(m -> {}));
int port = server.listen(0).toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS).actualPort();
for (int n = 0; n < 300; n++) {
try (var s = new Socket("127.0.0.1", port)) {
var req = ("GET /x HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+ "Sec-WebSocket-Key: " + SAMPLE_KEY + "\r\nSec-WebSocket-Version: 13\r\n\r\n").getBytes();
byte[] p = new byte[100]; byte[] m = {1,2,3,4};
var all = new ByteArrayOutputStream(); all.write(req); all.write(0x82); all.write(0x80 | p.length); all.write(m); all.write(p);
s.getOutputStream().write(all.toByteArray()); s.getOutputStream().flush();
s.getInputStream().read(new byte[200]);
}
if (n % 50 == 0) { System.gc(); Thread.sleep(50); }
}
System.gc(); Thread.sleep(500);
// allocate more so the detector reports
for (int n = 0; n < 50; n++) { try (var s = new Socket("127.0.0.1", port)) { s.getOutputStream().write("GET / HTTP/1.1\r\nHost: x\r\n\r\n".getBytes()); s.getInputStream().read(); } System.gc(); }
vertx.close();
}
}grep -c LEAK: gives 26 to 31 on 5.1.8, 26 to 28 on 5.2.2 and 0 on 4.5.35.
The detector samples, so this undercounts the leaks.
Expected
At minimum, handleOther releases what it does not handle, so no buffer leaks.
Ideally the frame is delivered to the ServerWebSocket once it is wired, as 4.5 did for a paused socket.
That probably means installing the WebSocket VertxHandler before handshaker.handshake(...) replaces the decoder, or buffering frames that arrive in between.
Not affected
A client that waits for the 101, which every conforming client does.
Extra
- JDK 25, Linux x86_64, Netty 4.2.19 (via vertx-core 5.2.2).
- Found while porting a Quarkus application to Quarkus 4 (Vert.x 5).
Activity
Sanne-ai-bot commentedon Oct 30, 2026
Review of eclipse-vertx#6476: one more case to handle
I verified the fix in eclipse-vertx#6476. It works:
master's code (timeout waiting for the echo).webSocketHandshakeHandler+accept().PipelinedFrameLeaksreports 0 leaks, against 27 on 5.2.2.Http1xTest,WebSocketTestand the HTTP/1 server suites pass, 800 tests and 0 failures.What is still missing: a frame pipelined with a failed upgrade is held until the connection closes
The frame does not reach
Http1ServerConnection.handleMessagestraight away.It is decoded while
VertxConnection.readis already insidehandleMessagefor the request'sLastHttpContent, so it waits inVertxConnection'spendingqueue.When the 101 write fails synchronously, the handshaker's listener calls
webSocketUpgradeFailed()before that frame leaves the queue.releasePendingWebSocketFrames()then finds nothing to release, and the frame is buffered after the cleanup.It is released only by
handleClosed(), so it stays allocated for as long as the connection stays open.The success path does not have this problem: once
webSocketUpgraded()has run, a late frame is forwarded to the WebSocket handler.A test that fails the 101 write shows it.
On the PR as it stands, the frame's refcount is still 1 after the upgrade has failed, and drops to 0 only after the connection closes.
The fix
Remember that the upgrade failed, and release late frames straight away:
The test (in
WebSocketTest, beside the PR's three) fails the 101 write in an outbound handler at the head of the pipeline.The handshaker writes the response from the encoder's context, so the handler matches the encoded bytes.
It checks the frame's refcount once the current read has been processed:
Without the flag it fails with
expected:<0> but was:<1>.With it, the four pipelined-frame tests and the whole
WebSocketTestpass (218 tests, 1 skipped as onmaster).Status of the commit
Pushed as
a4c5fa2c8("Release WebSocket frames pipelined with a failed upgrade", signed off) toSanne-ai-bot/vert.x:ws-pipelined-frame-leak, on top of00e6eff30.An earlier attempt failed with GitHub
500errors on every new git object write; a retry later succeeded unchanged.Smaller points a reviewer may raise (optional)
onWebSocketFramecallschannelReadCompleteonce per buffered frame. Calling it once after draining inwebSocketUpgraded()would be tidier.Deliver and release WebSocket frames pipelined with the server upgrad…