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
- Set a WebSocket endpoint up over HTTP/1.1 which terminates with an exit code of 4000 after a few seconds
- Point HAProxy at this with the backend and have a h2c frontend
- Have another backend that loops back to the h2c frontend and another frontend with alpn h2
- 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)
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 showSD--.Client -> HTTP2 -> HAProxy -> H2c -> HAProxy -> HTTP/1.1
Steps to Reproduce the Behavior
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--.What is your configuration?
Output of
haproxy -vvLast Outputs and Backtraces
Additional Information
client.py
server.py