Skip to content

Commit f218e22

Browse files
BUG/MEDIUM: cli: fix master CLI connection slot leak on client disconnect
In master-worker mode the master CLI proxy (mworker_proxy) has a hardcoded maxconn of 10. When a client connects to the master CLI socket and issues a command that gets forwarded to an unresponsive worker (e.g. one that is stuck or very slow), the connection hangs waiting for the worker's response. If the client then disconnects (timeout, Ctrl-C, etc.), the connection slot is never released because the client-side FIN is never propagated to tear down the backend. After 10 such leaked slots the master CLI socket becomes completely unreachable, returning "Resource temporarily unavailable" to any new connection attempt. The fix has three parts: 1) Remove sc_schedule_shutdown(s->scb) after command forwarding. The worker doesn't need the TCP FIN to know when a command ends - CLI commands are newline-delimited. Without this shutdown, the backend never enters half-close during normal command processing, so timeout server-fin is never implicitly armed by process_stream(). 2) In the AN_RES_WAIT_CLI early-return path, call sc_set_hcto(s->scb) only when the client has disconnected (SC_FL_EOS on scf) and there is no more pending request data. This arms the 1s server-fin timer exclusively in the stuck-worker scenario. 3) Call channel_dont_close(req) to prevent process_stream() from auto-forwarding the client's FIN to the backend via CF_AUTO_CLOSE. A 1s timeout server-fin is configured on mworker_proxy. It is only armed after the client disconnects cleanly, so it never fires during normal command processing. It ensures a stuck backend releases its connection slot promptly once the client is gone. Locally-handled commands (master applet) are unaffected because they complete synchronously before the client has reason to disconnect. The scb->ioto reset (TICK_ETERNITY) at end-of-transaction in pcli_wait_for_response() prevents any timer leakage between commands. This fixes GH issue haproxy#3351. This should be backported to all stable branches.
1 parent c6600d7 commit f218e22

4 files changed

Lines changed: 169 additions & 10 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
varnishtest "Bug fix: master CLI connection slots freed on client disconnect"
2+
3+
# Regression test for a master CLI socket connection leak in master-worker mode.
4+
#
5+
# mworker_proxy has a fixed maxconn of 10. When clients connect to the master
6+
# socket, send a command that is forwarded to a busy worker, and then close
7+
# the connection due to a client-side receive timeout, haproxy must free each
8+
# slot as the client disconnects.
9+
#
10+
# Without the fix: slots remain occupied after the client disconnects, so the
11+
# master CLI becomes unreachable once all 10 slots are filled.
12+
# With the fix: slots are freed on client disconnect and the master CLI keeps
13+
# accepting new connections.
14+
#
15+
# The worker is made unresponsive using "debug dev delay" (expert-mode) with
16+
# nbthread 1 so a single delay blocks the entire worker CLI. This avoids
17+
# using SIGSTOP/SIGCONT which can be unreliable in CI environments.
18+
19+
#REGTEST_TYPE=bug
20+
21+
feature cmd "command -v socat"
22+
feature cmd "command -v timeout"
23+
feature ignore_unknown_macro
24+
25+
server s1 {
26+
} -start
27+
28+
haproxy h1 -W -S -conf {
29+
global
30+
nbthread 1
31+
32+
defaults
33+
mode http
34+
timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
35+
timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
36+
timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
37+
38+
frontend fe
39+
bind "fd@${fe}"
40+
default_backend be
41+
42+
backend be
43+
server s1 ${s1_addr}:${s1_port}
44+
} -start
45+
46+
# Fill all 10 master CLI slots (mworker_proxy->maxconn is hardcoded to 10).
47+
# Each socat sends "expert-mode on" followed by "@1 debug dev delay 10000"
48+
# which blocks the single worker thread for 10 s. After 2 s the timeout(1)
49+
# wrapper kills socat, simulating a client-side receive timeout. "wait"
50+
# ensures all background processes have exited before proceeding.
51+
shell {
52+
for i in $(seq 1 10); do
53+
(printf "expert-mode on\n@1 debug dev delay 10000\n" \
54+
| timeout 2 socat TCP:${h1_mcli_addr}:${h1_mcli_port} - 2>/dev/null) &
55+
done
56+
wait
57+
}
58+
59+
# This is the key assertion: after all 10 clients have disconnected, a new
60+
# connection to the master CLI must succeed. With the bug all 10 slots are
61+
# still marked occupied and this connect is refused or times out.
62+
haproxy h1 -mcli {
63+
send "show version"
64+
expect ~ "3."
65+
}
66+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Delay worker readiness by 2s to test that serverfin does not
2+
-- kill the reload command while the new worker is starting.
3+
core.register_init(function()
4+
os.execute("sleep 2")
5+
end)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
varnishtest "Verify reload via master CLI is not affected by serverfin timeout"
2+
3+
# Regression test: the timeout server-fin on the MASTER proxy must not
4+
# apply to locally-handled commands (applets). A reload command is
5+
# forwarded to the master applet and may take longer than 1s while the
6+
# new worker is starting. Without the fix, the pcli response analyser
7+
# would fire a read timeout and return "Can't connect to the target CLI!"
8+
# instead of the reload status.
9+
10+
#REQUIRE_OPTIONS=LUA
11+
#REGTEST_TYPE=bug
12+
13+
feature cmd "command -v socat"
14+
feature cmd "$HAPROXY_PROGRAM -vv | grep -q '+LUA'"
15+
feature ignore_unknown_macro
16+
17+
server s1 {
18+
rxreq
19+
txresp
20+
} -start
21+
22+
haproxy h1 -W -S -conf {
23+
global
24+
tune.lua.bool-sample-conversion normal
25+
# Lua init hook that sleeps 2s, delaying worker readiness on reload
26+
lua-load ${testdir}/mcli_reload_no_timeout.lua
27+
28+
defaults
29+
mode http
30+
timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
31+
timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
32+
timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
33+
34+
frontend fe
35+
bind "fd@${fe}"
36+
default_backend be
37+
38+
backend be
39+
server s1 ${s1_addr}:${s1_port}
40+
} -start
41+
42+
# Issue a reload via socat as a second command (after "show version").
43+
# The first command causes CF_AUTO_CLOSE to be set on the request channel.
44+
# When socat half-closes its write side after sending, process_stream()
45+
# triggers sc_shutdown(scb) -> sc_set_hcto() which applies serverfin=1s
46+
# to the backend applet. Without the fix, the reload response (which comes
47+
# from the master applet) would be killed by this 1s timeout if the new
48+
# worker takes time to start, returning "Can't connect to the target CLI!".
49+
shell {
50+
RESULT=$(printf "show version\nreload\n" | socat -t10 TCP:${h1_mcli_addr}:${h1_mcli_port} - 2>/dev/null)
51+
echo "Got: $RESULT"
52+
echo "$RESULT" | grep -q "Success=1" || {
53+
echo "FAIL: reload did not succeed. Got: $RESULT"
54+
exit 1
55+
}
56+
}
57+
58+
# Verify the master CLI is still functional after reload
59+
shell {
60+
RESULT=$(printf "show version\n" | socat -t5 TCP:${h1_mcli_addr}:${h1_mcli_port} - 2>/dev/null)
61+
echo "$RESULT" | grep -q "3\." || {
62+
echo "FAIL: show version failed. Got: $RESULT"
63+
exit 1
64+
}
65+
}

src/cli.c

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3484,8 +3484,25 @@ int pcli_wait_for_request(struct stream *s, struct channel *req, int an_bit)
34843484
* current one. Just wait. At this stage, errors should be handled by
34853485
* the response analyzer.
34863486
*/
3487-
if (s->res.analysers & AN_RES_WAIT_CLI)
3487+
if (s->res.analysers & AN_RES_WAIT_CLI) {
3488+
/* Prevent process_stream from auto-forwarding the client close to
3489+
* the backend via the CF_AUTO_CLOSE check while we're waiting for
3490+
* a response - we manage the connection lifetime ourselves.
3491+
*/
3492+
channel_dont_close(req);
3493+
3494+
/* If the client disconnected cleanly and we have no more commands
3495+
* to send, arm the server-fin timer on the backend. A stuck
3496+
* worker that never responds will then be aborted after
3497+
* timeout server-fin, freeing the connection slot (GH #3351).
3498+
* When lra is TICK_ETERNITY (no data received yet) the timer never
3499+
* fires, so this is safe to call on every wakeup.
3500+
*/
3501+
if ((s->scf->flags & SC_FL_EOS) && !ci_data(req))
3502+
sc_set_hcto(s->scb);
3503+
34883504
return 0;
3505+
}
34893506

34903507
pcli->flags &= ~PCLI_F_BIDIR; // only for one connection
34913508
if ((pcli->flags & ACCESS_LVL_MASK) == ACCESS_LVL_NONE)
@@ -3528,13 +3545,7 @@ int pcli_wait_for_request(struct stream *s, struct channel *req, int an_bit)
35283545
else
35293546
channel_forward_forever(req);
35303547

3531-
if (!(pcli->flags & PCLI_F_PAYLOAD)) {
3532-
/* we send only 1 command per request, and we write
3533-
* close after it when not in full-duplex mode.
3534-
*/
3535-
if (!(pcli->flags & PCLI_F_BIDIR))
3536-
sc_schedule_shutdown(s->scb);
3537-
} else {
3548+
if (pcli->flags & PCLI_F_PAYLOAD) {
35383549
pcli_write_prompt(s);
35393550
}
35403551

@@ -3595,6 +3606,13 @@ int pcli_wait_for_request(struct stream *s, struct channel *req, int an_bit)
35953606
if (s->scf->flags & (SC_FL_ABRT_DONE|SC_FL_EOS)) {
35963607
/* There is no more request or a only a partial one and we
35973608
* receive a close from the client, we can leave */
3609+
if (s->scf->flags & SC_FL_ABRT_DONE) {
3610+
/* Client sent RST: abort the backend immediately.
3611+
* Note: when AN_RES_WAIT_CLI is set we never reach here
3612+
* (early return above), so this handles the pre-connect case.
3613+
*/
3614+
sc_schedule_abort(s->scb);
3615+
}
35983616
sc_schedule_shutdown(s->scf);
35993617
s->req.analysers &= ~AN_REQ_WAIT_CLI;
36003618
return 1;
@@ -3834,8 +3852,13 @@ int mworker_cli_create_master_proxy(char **errmsg)
38343852
mworker_proxy->mode = PR_MODE_CLI;
38353853
/* default to 10 concurrent connections */
38363854
mworker_proxy->maxconn = 10;
3837-
/* no timeout */
3838-
mworker_proxy->timeout.client = 0;
3855+
mworker_proxy->timeout.client = 0; /* no timeout */
3856+
/* 1s server-fin timeout: armed only after the client disconnects cleanly
3857+
* (scf EOS + no pending commands), so it never fires during normal command
3858+
* processing. It ensures a stuck backend releases its slot promptly once
3859+
* the client is gone.
3860+
*/
3861+
mworker_proxy->timeout.serverfin = MS_TO_TICKS(1000);
38393862
mworker_proxy->conf.file = strdup("MASTER");
38403863
mworker_proxy->conf.line = 0;
38413864
mworker_proxy->accept = frontend_accept;

0 commit comments

Comments
 (0)