Skip to content

WebSocket over HTTP2 logs "SD--" instead of "----" #3205

@chrisstaite

Description

@chrisstaite

Detailed Description of the Problem

When a WebSocket closes the final packet from the server is a close frame. This is a final data packet that transmits the exit code to the client.

When operating over a HTTP/1.1 backend this functions just fine as the data frame is handled and then the connection closure is handled.

When we chain two HAProxy instances together using H2C it transmits the close data frame and the stream closure together which causes the stream to be closed with SD-- rather than ---- as it is in the first case.

Expected Behavior

The WebSocket closure is passed from the backend to the client through the two HAProxy instances. The termination flags on both instances should be ---- and not show SD--.

Client -> HTTP2 -> HAProxy -> H2c -> HAProxy -> HTTP/1.1

Steps to Reproduce the Behavior

  1. Set a WebSocket endpoint up over HTTP/1.1 which terminates with an exit code of 4000 after a few seconds
  2. Point HAProxy at this with the backend and have a h2c frontend
  3. Have another backend that loops back to the h2c frontend and another frontend with alpn h2
  4. Connect to the h2 frontend for the WebSocket and monitor

Do you have any idea what may have caused this?

It appears that the tunnel is being marked as in error when it gets the close frame and stream closure at the same time the the mux_h2. This doesn't occur in mux_h1.

Do you have an idea how to solve the issue?

This resolves the issue with the exit frame being forwarded, but the log is still incorrectly marked as SD--.

diff --git a/src/mux_h2.c b/src/mux_h2.c
index 39119ce4f..bfa3b2d90 100644
--- a/src/mux_h2.c
+++ b/src/mux_h2.c
@@ -5704,7 +5704,7 @@ static void h2_do_shutr(struct h2s *h2s, struct se_abort_info *reason)
                h2c_error(h2c, H2_ERR_ENHANCE_YOUR_CALM);
                h2s_error(h2s, H2_ERR_ENHANCE_YOUR_CALM);
        }
-       else if (h2s_is_forwardable_abort(h2s, reason)) {
+       else if (!(h2s->flags & H2_SF_BODY_TUNNEL) && h2s_is_forwardable_abort(h2s, reason)) {
                TRACE_STATE("shutr using opposite endp code", H2_EV_STRM_SHUT, h2c->conn, h2s);
                h2s_error(h2s, reason->code);
        }

What is your configuration?

global
    log stdout format raw local0

defaults
    mode http
    log global
    option httplog
    timeout connect 5s
    timeout client  5s
    timeout server  5s
    timeout tunnel  1h

frontend fe_entry
    bind :8001
    default_backend be_to_h2c

backend be_to_h2c
    server internal_h2 127.0.0.1:8002 proto h2

frontend fe_h2c_inter
    bind :8002 proto h2
    default_backend be_final

backend be_final
    server app_server 127.0.0.1:8003

Output of haproxy -vv

HAProxy version 3.4-dev0-d2a166-13 2025/12/04 - https://haproxy.org/
Status: development branch - not safe for use in production.
Known bugs: https://github.com/haproxy/haproxy/issues?q=is:issue+is:open
Running on: Darwin 25.1.0 Darwin Kernel Version 25.1.0: Mon Oct 20 19:26:51 PDT 2025; root:xnu-12377.41.6~2/RELEASE_X86_64 x86_64
Build options : 
  TARGET  = osx
  CC      = cc
  CFLAGS  = -O2 -g -fwrapv
  OPTIONS = 
  DEBUG   = 

Feature list : -51DEGREES -ACCEPT4 -BACKTRACE -CLOSEFROM +CPU_AFFINITY -CRYPT_H -DEVICEATLAS -DL -ECH -ENGINE -EPOLL -EVPORTS +GETADDRINFO +KQUEUE -KTLS -LIBATOMIC +LIBCRYPT -LINUX_CAP -LINUX_SPLICE -LINUX_TPROXY -LUA -MATH -MEMORY_PROFILING -NETFILTER -NS -OBSOLETE_LINKER -OPENSSL -OPENSSL_AWSLC -OPENSSL_WOLFSSL -OT -PCRE -PCRE2 -PCRE2_JIT -PCRE_JIT +POLL -PRCTL -PROCCTL -PROMEX -PTHREAD_EMULATION -QUIC -QUIC_OPENSSL_COMPAT -RT -SHM_OPEN +SLZ -SSL -STATIC_PCRE -STATIC_PCRE2 -TFO +THREAD -THREAD_DUMP +TPROXY -WURFL -ZLIB

Default settings :
  bufsize = 16384, maxrewrite = 1024, maxpollevents = 200

Built with multi-threading support (MAX_TGROUPS=32, MAX_THREADS=1024, default=8).
Built with libslz for stateless compression.
Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
Built with transparent proxy support using:
Built without PCRE or PCRE2 support (using libc's regex instead)
Encrypted password support via crypt(3): yes
Built with clang compiler version 17.0.0 (clang-1700.4.4.1)

Available polling systems :
     kqueue : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result OK
Total: 3 (3 usable), will use kqueue.

Available multiplexer protocols :
(protocols marked as <default> cannot be specified using 'proto' keyword)
         h2 : mode=HTTP  side=FE|BE  mux=H2    flags=HTX|HOL_RISK|NO_UPG
         h1 : mode=HTTP  side=FE|BE  mux=H1    flags=HTX|NO_UPG
  <default> : mode=HTTP  side=FE|BE  mux=H1    flags=HTX
       fcgi : mode=HTTP  side=BE     mux=FCGI  flags=HTX|HOL_RISK|NO_UPG
       spop : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
  <default> : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
       none : mode=TCP   side=FE|BE  mux=PASS  flags=NO_UPG
  <default> : mode=TCP   side=FE|BE  mux=PASS  flags=

Available services : none

Available filters :
	[BWLIM] bwlim-in
	[BWLIM] bwlim-out
	[CACHE] cache
	[COMP] compression
	[FCGI] fcgi-app
	[SPOE] spoe
	[TRACE] trace

Last Outputs and Backtraces

127.0.0.1:49465 [04/Dec/2025:21:54:25.858] fe_h2c_inter be_final/app_server 0/0/0/1/1005 101 297 - - ---- 2/1/0/0/0 0/0 "GET https://127.0.0.1:8001/ HTTP/2.0"
127.0.0.1:49464 [04/Dec/2025:21:54:25.857] fe_entry be_to_h2c/internal_h2 0/0/0/2/1007 101 275 - - SD-- 2/1/0/0/0 0/0 "GET / HTTP/1.1"

Additional Information

client.py

import asyncio
import websockets
import sys

async def test_client():
    uri = "ws://127.0.0.1:8001"
    print(f"Connecting to {uri}...")
    
    try:
        async with websockets.connect(uri) as websocket:
            print("Connected!")
            
            # Loop to receive messages until the server closes the connection
            try:
               async for message in websocket:
                  print(f"< Received: {message}")
            except websockets.exceptions.ConnectionClosed:
               pass
                
            # Check for the expected behavior
            if websocket.close_code == 4000:
                print("✅ SUCCESS: Received clean close code 4000.")
            elif websocket.close_code == 1006:
               print("❌ FAILURE: Received abnormal close code 1006 (Abnormal Closure).")
               print("   This likely means the close frame was lost/reset.")
            else:
               print(f"⚠️  Received unexpected code: {websocket.code}")

    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    try:
        asyncio.run(test_client())
    except KeyboardInterrupt:
        sys.exit(0)

server.py

import asyncio
import websockets
import sys

async def echo(websocket):
    print("Client connected")
    try:
        await websocket.send("Hello from Backend")
        # Simulate server-side work, then close cleanly
        await asyncio.sleep(1)
        print("Initiating clean close (Code 4000)...")
        await websocket.close(code=4000, reason="Normal Closure")
        print("Close frame sent.")
    except Exception as e:
        print(f"Error: {e}")

async def main():
    async with websockets.serve(echo, "127.0.0.1", 8003):
        print("Server listening on :8003")
        await asyncio.Future()  # run forever

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        sys.exit(0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    2.4This issue affects the HAProxy 2.4 stable branch.2.6This issue affects the HAProxy 2.6 stable branch.status: fixedThis issue is a now-fixed bug.type: bugThis issue describes a bug.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions