bryancall commented on code in PR #13681:
URL: https://github.com/apache/trafficserver/pull/13681#discussion_r4017847457
##########
tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue.test.py:
##########
@@ -1,9 +1,16 @@
'''
-Regression test for a queue-accounting balance bug in the rate_limit SNI
limiter: a
-queued connection never reserves a slot, but its VCONN_CLOSE unconditionally
releases
-one, so a queued connection that closes underflows the active-slot counter and
the next
-reserve() trips a release assertion, aborting the server. ATS must survive the
queue
-churn.
+Regression test for the rate_limit SNI limiter's queue accounting. A queued
connection never
+reserves a slot, so the sweep must reserve one before resuming it and a close
must release
+only a slot the connection owns. Before 508c1bea26 the sweep resumed a queued
connection
+while the limiter was still full: the holder's close then landed the
active-slot counter on
+zero, the resumed connection's close released a slot it never held and wrapped
the counter
+below zero, and the next reserve() tripped a release assertion that aborted
the server. ATS
+must survive the queue churn without the counter wrapping.
Review Comment:
Done in 5a5b7eb21b. The docstring now states the accounting invariant and its
failure mode in the present tense, with no commit reference.
##########
tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue_client.sh:
##########
@@ -20,59 +20,182 @@
# and check that the active-slot counter stays balanced and the server
survives.
#
# 1. holder completes its handshake and holds the single slot (counter = 1);
-# 2. one connection enqueues because the slot is full, then closes while
parked;
-# 3. the sweep reserves a slot and resumes a queued connection;
-# 4. the holder is closed and releases its slot;
+# 2. one connection enqueues because the slot is full and stays parked at
the ClientHello hook;
+# 3. the sweep runs while the slot is still held and must leave the queued
connection parked;
+# 4. the holder is closed and releases its slot; the next sweep reserves
that slot and resumes
+# the queued connection, which is then closed and releases the slot it
now owns;
# 5. a probe connection reserves the freed slot.
#
-# Against the plugin before 508c1bea26 this aborts the server: the sweep
resumed a queued
-# connection without a reservation, whose close then decremented the counter
unmatched until it
-# wrapped and reserve() tripped TSReleaseAssert(_active <= _limit). The test
asserts the counter
-# never wraps and no signal is logged, so it pins that fix as well as this
change.
+# Against the plugin before 508c1bea26 the sweep resumed the queued connection
in step 3 without
+# a reservation. Step 4 asserts that reservation and fails there, naming the
defect itself. Left
+# to run, that same resume aborts the server: the holder's close lands the
counter on zero and the
+# resumed connection's close, releasing a slot it never held, wraps it below
zero, so the probe's
+# reserve() trips TSReleaseAssert(_active <= _limit). The test's traffic.out
testers reject that
+# wrap and that abort however they arise, and stay the backstop for both.
Review Comment:
Done in 5a5b7eb21b. Dropped the commit reference here, and also the "do not
reintroduce a closes-while-parked step" aside, which was really describing an
older shape of the test rather than anything a reader needs. What is left is
the
invariant and why the close-while-queued branch cannot be driven from a
client.
##########
tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue_client.sh:
##########
@@ -20,59 +20,182 @@
# and check that the active-slot counter stays balanced and the server
survives.
#
# 1. holder completes its handshake and holds the single slot (counter = 1);
-# 2. one connection enqueues because the slot is full, then closes while
parked;
-# 3. the sweep reserves a slot and resumes a queued connection;
-# 4. the holder is closed and releases its slot;
+# 2. one connection enqueues because the slot is full and stays parked at
the ClientHello hook;
+# 3. the sweep runs while the slot is still held and must leave the queued
connection parked;
+# 4. the holder is closed and releases its slot; the next sweep reserves
that slot and resumes
+# the queued connection, which is then closed and releases the slot it
now owns;
# 5. a probe connection reserves the freed slot.
#
-# Against the plugin before 508c1bea26 this aborts the server: the sweep
resumed a queued
-# connection without a reservation, whose close then decremented the counter
unmatched until it
-# wrapped and reserve() tripped TSReleaseAssert(_active <= _limit). The test
asserts the counter
-# never wraps and no signal is logged, so it pins that fix as well as this
change.
+# Against the plugin before 508c1bea26 the sweep resumed the queued connection
in step 3 without
+# a reservation. Step 4 asserts that reservation and fails there, naming the
defect itself. Left
+# to run, that same resume aborts the server: the holder's close lands the
counter on zero and the
+# resumed connection's close, releasing a slot it never held, wraps it below
zero, so the probe's
+# reserve() trips TSReleaseAssert(_active <= _limit). The test's traffic.out
testers reject that
+# wrap and that abort however they arise, and stay the backstop for both.
#
-# args: host port sni
+# The connection is resumed and then closed, never closed while parked,
because a client cannot
+# close a parked connection as far as ATS is concerned: while the ClientHello
hook is invoked ATS
+# does not read the socket, so a FIN sits in the kernel until the sweep
reenables the VC. The
+# close-while-queued branch of sni_limiter.cc is therefore out of scope here
and cannot be driven
+# from a client; do not reintroduce a "closes while parked" step, it only ever
resolved to the
+# resume-then-close path this script now drives deterministically.
+#
+# Every step waits for the plugin's own debug line in traffic.out rather than
sleeping for a
+# guessed interval: on a loaded runner a fixed sleep let the second connection
miss the holder
+# entirely, so the queue path was silently never exercised (#13679). Every
connection reads
Review Comment:
Agreed on the principle, and applied it across both files in 5a5b7eb21b: no
commit SHAs, no issue numbers, and nothing about how the test used to behave.
That paragraph now says only why the steps key on the plugin's own debug
lines
instead of sleeps, and why each connection's open and close is taken out of
s_client's hands.
The change is comments only -- stripping comments from the shell script
leaves it
byte-identical to the previous commit, and the test.py AST outside the
docstring
is unchanged. Still passes on Linux and macOS.
##########
tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue_client.sh:
##########
@@ -20,59 +20,158 @@
# and check that the active-slot counter stays balanced and the server
survives.
#
# 1. holder completes its handshake and holds the single slot (counter = 1);
-# 2. one connection enqueues because the slot is full, then closes while
parked;
-# 3. the sweep reserves a slot and resumes a queued connection;
-# 4. the holder is closed and releases its slot;
+# 2. one connection enqueues because the slot is full and stays parked at
the ClientHello hook;
+# 3. the sweep runs while the slot is still held and must leave the queued
connection parked;
+# 4. the holder is closed and releases its slot; the next sweep reserves
that slot and resumes
+# the queued connection, which is then closed and releases the slot it
now owns;
# 5. a probe connection reserves the freed slot.
#
-# Against the plugin before 508c1bea26 this aborts the server: the sweep
resumed a queued
-# connection without a reservation, whose close then decremented the counter
unmatched until it
-# wrapped and reserve() tripped TSReleaseAssert(_active <= _limit). The test
asserts the counter
-# never wraps and no signal is logged, so it pins that fix as well as this
change.
+# Against the plugin before 508c1bea26 this aborts the server: the sweep
resumed the queued
+# connection in step 3 without a reservation, so the holder's close in step 4
landed the counter
+# on zero and the resumed connection's close, releasing a slot it never held,
wrapped the counter
+# below zero; the probe's reserve() then tripped TSReleaseAssert(_active <=
_limit). The test
+# asserts no release wraps and no signal is logged, so it pins that fix.
#
-# args: host port sni
+# The connection is resumed and then closed, never closed while parked,
because a client cannot
+# close a parked connection as far as ATS is concerned: while the ClientHello
hook is invoked ATS
+# does not read the socket, so a FIN sits in the kernel until the sweep
reenables the VC. The
+# close-while-queued branch of sni_limiter.cc is therefore out of scope here
and cannot be driven
+# from a client; do not reintroduce a "closes while parked" step, it only ever
resolved to the
+# resume-then-close path this script now drives deterministically.
+#
+# Every step waits for the plugin's own debug line in traffic.out rather than
sleeping for a
+# guessed interval: on a loaded runner a fixed sleep let the second connection
miss the holder
+# entirely, so the queue path was silently never exercised (#13679). Every
connection reads
+# stdin from a FIFO that never delivers data, and is ended with kill -TERM on
openssl's own PID.
+# Neither the moment a connection opens nor the moment it closes is left to
s_client: how it
+# reacts to EOF on stdin differs between OpenSSL and LibreSSL, so a connection
that must stay up
+# is given stdin that never becomes readable, and one that must go is killed
outright. The plugin
+# only needs the VCONN_CLOSE that the resulting FIN produces.
+#
+# args: host port sni traffic_out
set -u
host="$1"
port="$2"
sni="$3"
+traffic_out="$4"
+
+OSSL="openssl s_client -connect ${host}:${port} -servername ${sni} -quiet"
-OSSL="openssl s_client -connect ${host}:${port} -servername ${sni} -quiet
-no_ign_eof"
+# Ceiling per step. It only bounds a broken run; a healthy one moves on as
soon as the line
+# appears.
+WAIT_LIMIT=30
-# Run a command in the background and terminate it after a deadline. coreutils
"timeout" is not
-# available everywhere (notably macOS), so do it with sleep and kill.
-run_for() {
- deadline="$1"
- shift
- "$@" &
- target=$!
- (
- sleep "${deadline}"
- kill -TERM "${target}" 2>/dev/null
- ) &
+holder=""
+queued=""
+probe=""
+
+cleanup() {
+ kill -TERM ${holder:-} ${queued:-} ${probe:-} 2>/dev/null || true
}
+trap cleanup EXIT
-# 1. Holder: hold the single slot. Its stdin is a FIFO kept open on fd 3, so
we end the
-# holder deterministically in step 4 (closing fd 3 -> EOF -> clean TLS
close -> FIN).
+# Shared stdin for every connection: a FIFO held open read-write on fd 3 that
nothing ever
+# writes to, so reads block and polls stay idle until the process is killed.
Opening it
+# read-write does not block, and the connections inherit fd 3 rather than
opening the path,
+# so the directory can go straight away.
fifo_dir="$(mktemp -d "${TMPDIR:-/tmp}/rl_holder.XXXXXX")"
-fifo="${fifo_dir}/fifo"
-mkfifo "$fifo"
-${OSSL} <"$fifo" >/dev/null 2>&1 &
-exec 3<>"$fifo"
+mkfifo "${fifo_dir}/fifo"
+exec 3<>"${fifo_dir}/fifo"
rm -rf "$fifo_dir"
-sleep 3 # let the holder reserve the one slot
-# 2. One queued connection: enqueues because the slot is full, then closes
while still parked
-# at the ClientHello hook.
-run_for 0.3 sh -c "${OSSL} </dev/null >/dev/null 2>&1"
-sleep 2 # >= 2 sweep periods (300ms each), so the sweep runs while the
connection is queued
+# Poll traffic.out until the fixed string has appeared at least count times.
Diags serialises
+# each debug line and flushes it whole, so polling can never see a
half-written line and a line
+# is visible as soon as the plugin emits it. On timeout, fail loudly with what
was expected and
+# what the log holds so the sandbox is diagnosable.
+wait_for() {
+ needle="$1"
+ count="$2"
+ end=$((SECONDS + WAIT_LIMIT))
+ while :; do
+ seen=$(grep -c -F -- "$needle" "$traffic_out" 2>/dev/null || true)
+ if [ "${seen:-0}" -ge "$count" ]; then
+ return 0
+ fi
+ if [ "$SECONDS" -ge "$end" ]; then
+ echo "timed out after ${WAIT_LIMIT}s waiting for occurrence ${count} of
'${needle}' (saw ${seen:-0})" >&2
+ echo "--- tail of ${traffic_out}:" >&2
+ tail -n 20 "$traffic_out" >&2
+ exit 1
+ fi
+ sleep 0.1
+ done
+}
+
+# Close a connection by killing openssl itself. Escalate to KILL rather than
waiting forever:
+# a TERM-immune s_client would otherwise stall the run until autest's process
timeout, turning a
+# seconds-long failure into a ten-minute one.
+#
+# The whole body is redirected because the shell announces a signal-killed
background job on the
+# script's stderr as it reaps it ("Terminated: 15"), at whatever command
happens to be running --
+# redirecting the wait alone does not catch it. That noise would sit in
stream.stderr.txt next to
+# this script's real diagnostics, so keep it out and report an escalation on
the saved fd instead.
+end_connection() {
+ exec 4>&2
+ {
+ kill -TERM "$1" 2>/dev/null || true
+ waited=0
+ while kill -0 "$1" 2>/dev/null; do
+ if [ "$waited" -ge 30 ]; then
+ echo "pid $1 ignored TERM after 3s; escalating to KILL" >&4
+ kill -KILL "$1" 2>/dev/null || true
+ break
+ fi
+ waited=$((waited + 1))
+ sleep 0.1
+ done
+ wait "$1" || true
+ } 2>/dev/null
+ exec 4>&-
+}
+
+# 1. Holder: take the single slot.
+${OSSL} <&3 >/dev/null 2>&1 &
+holder=$!
+wait_for 'Reserving a slot, active entities == 1' 1
+
+# 2. Queued connection: the slot is full, so it is parked at the ClientHello
hook.
+${OSSL} <&3 >/dev/null 2>&1 &
+queued=$!
+wait_for 'Queueing the VC, we are at capacity' 1
+
+# 3. Let the sweep (every 300ms) run while the slot is still held. This is a
lower bound, not a
+# race: a correct sweep leaves the connection parked, which produces no
line to wait for,
+# and a slower runner only gives it more sweeps.
+sleep 1
+
+# A resume here means the sweep dequeued while the limiter was still full,
which is the bug this
+# test pins. Only report it: the run must continue so the wrapped counter and
the abort still
+# reach the test's traffic.out assertions, but naming the first wrong event
keeps it from being
+# buried behind the probe's timeout 30s later.
+resumed=$(grep -c -F -- 'Enabling queued VC' "$traffic_out" 2>/dev/null ||
true)
+if [ "${resumed:-0}" -ne 0 ]; then
+ echo "the sweep resumed the queued connection while the limiter was still
full" >&2
+fi
Review Comment:
Fixed in 9e106d6a11. The absolute count did make that wait vacuous: a resume
emitted during the full-slot interval satisfied `wait_for 'Enabling queued
VC' 1`
on its own.
I did not use a before/after count, because the obvious ordering check is
racy.
Comparing the resume against the holder's `Releasing a slot` would flake:
`free()` logs *after* dropping `_active_lock`, so the sweep can reserve,
resume
and log in the gap between the decrement and the release's own `Dbg`, and a
correct build would log the resume first.
The test now asserts the sweep's own reservation ahead of the resume. The
sweep
reserves a slot and then logs the resume from the same continuation, so
those two
lines are in program order rather than thread-scheduling order -- both are
`ET_TASK 0`:
```
7: [ET_NET 7] Reserving a slot, active entities == 1 <- holder
9: [ET_NET 8] Queueing the VC, we are at capacity
10: [ET_NET 7] Releasing a slot, active entities == 0
11: [ET_TASK 0] Reserving a slot, active entities == 1 <- sweep
12: [ET_TASK 0] Enabling queued VC after 1293ms
```
With the one-line revert of 508c1bea26 the sweep resumes without reserving,
so
only the holder's reservation is above the resume:
```
7: [ET_NET 7] Reserving a slot, active entities == 1
9: [ET_NET 8] Queueing the VC, we are at capacity
10: [ET_TASK 0] Enabling queued VC after 114ms <- no reservation
```
This also made the unfixed-build failure far faster and more legible. It took
30.6s before, because the client burned its whole `WAIT_LIMIT` waiting for a
third reservation that could never arrive -- ATS had already aborted. It now
fails in 1.5s, naming the cause:
```
the sweep resumed the queued connection without reserving a slot for it:
1 reservation(s) logged before the resume, expected the holder's and the
sweep's
```
The `traffic.out` testers are unchanged and stay hard, and the counter-wrap
tester still fires on the unfixed build, since the EXIT trap closes the
resumed
connection. Verified on Linux and macOS: 8/8 clean and 8/8 under CPU load on
each, and the revert fails on both.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]