Skip to content

Server WebSocket: a frame pipelined with the upgrade request is dropped and its buffer leaks (regression from 4.5) #1

Open

Description

@Sanne-ai-bot

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

  1. Sanne-ai-bot commented on Oct 30, 2026

    @Sanne-ai-bot
    Author

    Review of eclipse-vertx#6476: one more case to handle

    I verified the fix in eclipse-vertx#6476. It works:

    • The three new tests pass, and all three fail on master's code (timeout waiting for the echo).
    • With the reproducers from this issue on a build of the PR, the pipelined frame arrives in every mode, including webSocketHandshakeHandler + accept(). PipelinedFrameLeaks reports 0 leaks, against 27 on 5.2.2.
    • No regressions: Http1xTest, WebSocketTest and the HTTP/1 server suites pass, 800 tests and 0 failures.
    • incus-spawn's MITM proxy test for this case passes against the PR build.

    What is still missing: a frame pipelined with a failed upgrade is held until the connection closes

    The frame does not reach Http1ServerConnection.handleMessage straight away.
    It is decoded while VertxConnection.read is already inside handleMessage for the request's LastHttpContent, so it waits in VertxConnection's pending queue.
    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:

    --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ServerConnection.java
    +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ServerConnection.java
    @@ -98,6 +98,7 @@ public class Http1ServerConnection extends Http1Connection implements HttpServer
       // WebSocket upgrade: frames decoded before the WebSocket handler replaces this connection handler
       private ArrayDeque<WebSocketFrame> pendingWebSocketFrames;
       private VertxHandler<?> webSocketHandler;
    +  private boolean webSocketUpgradeFailed;
     
       public final HttpServerMetrics httpMetrics;
       private final TransportMetrics<?> transportMetrics;
    @@ -266,6 +267,11 @@ public class Http1ServerConnection extends Http1Connection implements HttpServer
        * connection. Such frame is buffered until the upgrade completes and then forwarded to the WebSocket handler.
        */
       private void onWebSocketFrame(WebSocketFrame frame) {
    +    if (webSocketUpgradeFailed) {
    +      // The upgrade failed before this frame left the inbound queue
    +      ReferenceCountUtil.release(frame);
    +      return;
    +    }
         VertxHandler<?> handler = webSocketHandler;
         if (handler == null) {
           // Upgrade in progress
    @@ -317,6 +323,7 @@ public class Http1ServerConnection extends Http1Connection implements HttpServer
        */
       public void webSocketUpgradeFailed() {
         assert chctx.executor().inEventLoop();
    +    webSocketUpgradeFailed = true;
         releasePendingWebSocketFrames();
       }
     

    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:

      @Test
      public void testFramePipelinedWithUpgradeRequestFailedUpgrade() throws Exception {
        server = vertx.createHttpServer(new HttpServerOptions().setPort(DEFAULT_HTTP_PORT));
        server.webSocketHandler(ws -> fail("upgrade should fail"));
        List<io.netty.buffer.ByteBuf> frames = Collections.synchronizedList(new ArrayList<>());
        java.util.concurrent.CompletableFuture<Integer> refCntAfterFailure = new java.util.concurrent.CompletableFuture<>();
        server.connectionHandler(conn -> {
          ChannelHandlerContext chctx = ((Http1ServerConnection) conn).channelHandlerContext();
          chctx.pipeline().addBefore("handler", "tracker", new ChannelInboundHandlerAdapter() {
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) {
              if (msg instanceof io.netty.handler.codec.http.websocketx.WebSocketFrame) {
                frames.add(((io.netty.handler.codec.http.websocketx.WebSocketFrame) msg).content());
              }
              ctx.fireChannelRead(msg);
            }
          });
          // Fail the 101 write
          chctx.pipeline().addFirst("fail101", new ChannelOutboundHandlerAdapter() {
            @Override
            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
              if (msg instanceof io.netty.buffer.ByteBuf
                && ((io.netty.buffer.ByteBuf) msg).toString(StandardCharsets.ISO_8859_1).startsWith("HTTP/1.1 101")) {
                io.netty.util.ReferenceCountUtil.release(msg);
                promise.setFailure(new java.io.IOException("simulated 101 write failure"));
                // Check the frame once the current read has been processed
                ctx.executor().execute(() -> refCntAfterFailure.complete(frames.isEmpty() ? -1 : frames.get(0).refCnt()));
                return;
              }
              ctx.write(msg, promise);
            }
          });
        });
        server.listen().await();
        byte[] mask = { 1, 2, 3, 4 };
        byte[] payload = "hi".getBytes(StandardCharsets.UTF_8);
        Buffer request = Buffer.buffer("GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
          "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n")
          .appendByte((byte) 0x81).appendByte((byte) (0x80 | payload.length)).appendBytes(mask);
        for (int i = 0; i < payload.length; i++) {
          request.appendByte((byte) (payload[i] ^ mask[i % 4]));
        }
        NetSocket so = vertx.createNetClient().connect(DEFAULT_HTTP_PORT, DEFAULT_HTTP_HOST).await();
        so.write(request);
        // The frame is released once the upgrade fails, not only when the connection closes
        assertEquals(0, (int) refCntAfterFailure.get(10, TimeUnit.SECONDS));
        assertEquals(1, frames.size());
        so.close();
      }

    Without the flag it fails with expected:<0> but was:<1>.
    With it, the four pipelined-frame tests and the whole WebSocketTest pass (218 tests, 1 skipped as on master).

    Status of the commit

    Pushed as a4c5fa2c8 ("Release WebSocket frames pipelined with a failed upgrade", signed off) to Sanne-ai-bot/vert.x:ws-pipelined-frame-leak, on top of 00e6eff30.
    An earlier attempt failed with GitHub 500 errors on every new git object write; a retry later succeeded unchanged.

    Smaller points a reviewer may raise (optional)

    • onWebSocketFrame calls channelReadComplete once per buffered frame. Calling it once after draining in webSocketUpgraded() would be tidier.
    • Nothing caps the buffer. It only lasts until the 101 is flushed, so it is bounded in practice, but a reviewer may ask.
    • A client cannot legitimately compress a frame before the server has accepted permessage-deflate. Such a frame used to be dropped; it now reaches the application before the inflater is installed. Rejecting it may be preferable. This case is not tested.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions