Deliver VCONN_CLOSE for parked TLS hooks; fix SNI queue accounting - #13406
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes correctness and concurrency issues in the experimental rate_limit plugin’s SNI queue handling during TLS handshakes, preventing active-slot counter underflow and stabilizing sweep vs. close interactions. Adds gold tests to regression-test the queue, expiry, and reject paths against a TLS listener.
Changes:
- Fix SNI queue slot accounting by reserving before dequeue/resume, detaching expired queued VCs, and only releasing slots for VCs that actually own a slot.
- Synchronize the periodic sweep and
TS_EVENT_VCONN_CLOSEhandling with a shared mutex to prevent sweep/close interleavings corrupting queue / slot / lease state. - Add new AuTest gold tests (plus bash+openssl clients) covering reject, queue, and max_age expiry behaviors.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_reject.test.py | Adds an autest covering the no-queue reject path under TLS. |
| tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_reject_client.sh | Bash/openssl client to generate concurrent handshakes to trigger rejects. |
| tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue.test.py | Adds a regression autest for queued-VC close vs. slot underflow. |
| tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue_client.sh | Deterministic bash/openssl reproducer for the historical underflow scenario. |
| tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_expiry.test.py | Adds a regression autest for the max_age expiry accounting path. |
| tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_expiry_client.sh | Bash/openssl client to hold a slot while a queued VC ages out. |
| plugins/experimental/rate_limit/sni_selector.cc | Fixes sweep logic (reserve→pop→reenable) and detaches expired queued VCs; adds sweep/close synchronization. |
| plugins/experimental/rate_limit/sni_limiter.cc | Fixes close-time accounting (remove-if-queued vs free-slot) and serializes with sweep under the shared mutex. |
| plugins/experimental/rate_limit/limiter.h | Adds remove() to drop still-queued elements so queued closes don’t decrement the active slot counter. |
f7e15a5 to
516e40c
Compare
cmcfarlen
left a comment
There was a problem hiding this comment.
A bit concerned that the rate_limit ops are now globally serialized. Consider finer grained locks.
|
|
||
| // Shared lock (defined in sni_limiter.cc) serializing the queue/slot transactions below | ||
| // against the net-thread VCONN_CLOSE handler. | ||
| extern std::mutex gQueueMutex; |
There was a problem hiding this comment.
Could the lock be per-limiter instead of global?
There was a problem hiding this comment.
Could the lock be per-limiter instead of global?
I removed the global lock, so the only locking left is per-limiter: the
_queue_lock and _active_lock that RateLimiter already had.
| { | ||
| std::lock_guard<std::mutex> lock(_queue_lock); | ||
|
|
||
| for (auto it = _queue.begin(); it != _queue.end(); ++it) { |
There was a problem hiding this comment.
how big can queue get?
Unbounded by default, which I agree is not sensible.
_max_queue is 0 (no queue) until a queue: block appears, and then
limiter.h:214 is:
_max_queue = queue["size"] ? queue["size"].as<uint32_t>() : UINT32_MAX;A queue: block without a size: gets UINT32_MAX, and
full() (_size >= max_queue()) can then never trip. The practical ceiling
becomes proxy.config.net.connections_throttle, 30000 by default.
_queue is a std::deque, so an erase from the middle is O(n). I'm filing an issue: #13511 to update defaults, and revisit this queue data structure.
A queued SNI connection never reserves a slot, but its VCONN_CLOSE released one unconditionally. A queued connection that closed therefore decremented the active-slot counter without a matching increment; it wrapped below zero and the next reserve() aborted the server on TSReleaseAssert(_active <= _limit). Balance the accounting: resume queued connections with reserve-then-pop so a resumed connection owns a real slot; release a slot on close only when the connection is no longer queued (a still-queued one never held one) and drop it from the queue; detach an expired connection the same way the reject path does. Removing a closing connection from the queue also fixes a stale-pointer dereference when a parked queued connection is reset. Add deterministic regressions for the resume and max_age paths.
Exercise the sync-reject path against a TLS listener: a holder reserves the one slot and a burst of concurrent handshakes is rejected mid-handshake (TS_EVENT_ERROR) with the allocator freelists disabled. Asserts the reject path is reached and every rejected handshake VC is freed without a memory-safety fault.
Annotate the TestRun parameters like the surrounding class-based gold tests, and create the holder FIFO inside a fresh mktemp -d directory instead of on an unlinked mktemp -u path, whose creation is not atomic.
callHooks() moves the hook state to DONE when a connection closes, but it kept curHook pointing into whichever handshake hook list the connection was parked in. Each hook id owns a separate list, so advancing curHook walked the handshake list rather than the close list: the close event was dropped once that list ran out, and delivered to the next handshake plugin when it did not. A plugin that parks a connection therefore never learns that it died. In the rate_limit SNI queue that leaves a freed TSVConn on the queue and leaks the selector lease, and the next sweep reenables freed memory. Restart from the head of the close hook list unless we are already iterating it. Take the same path for TS_EVENT_VCONN_OUTBOUND_CLOSE, which previously invoked nothing at all for a connection parked in the outbound pre-handshake hook.
5b1dece to
313a70f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (6)
tests/gold_tests/tls_hooks/tls_hooks_close_while_parked.test.py:76
tr.StillRunningAfteris assigned twice, so the first assignment is overwritten. If the harness expectsStillRunningAfterto track multiple processes, this will likely only assert one of them is still running. Use the framework’s supported way to register multiple processes (e.g., a list/collection API if available) so bothtsandserverare checked.
tr.StillRunningAfter = ts
tr.StillRunningAfter = server
tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue_client.sh:53
- These gold tests rely on the external
timeoututility and on it supporting fractional durations (0.3).timeout(and fractional support) is not consistently available across all CI/OS environments (e.g., some BSD/macOS setups). Consider implementing the timeout behavior in a more portable way (e.g., via Python in the test harness, or a small helper that is already used elsewhere in this repo’s gold tests) to avoid platform-specific flakes.
timeout 0.3 ${OSSL} </dev/null >/dev/null 2>&1 &
tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue_client.sh:62
- These gold tests rely on the external
timeoututility and on it supporting fractional durations (0.3).timeout(and fractional support) is not consistently available across all CI/OS environments (e.g., some BSD/macOS setups). Consider implementing the timeout behavior in a more portable way (e.g., via Python in the test harness, or a small helper that is already used elsewhere in this repo’s gold tests) to avoid platform-specific flakes.
timeout 2 ${OSSL} </dev/null >/dev/null 2>&1 || true
tests/gold_tests/pluginTest/rate_limit/rate_limit_sni_queue.test.py:28
- The new
rate_limitgold tests shell out toopenssl s_clientwith flags that may not exist on non-OpenSSL implementations (e.g., LibreSSL) and will fail ifopensslisn’t present. Add an explicit skip gate for the required client tooling/version (similar to thetls_hooks_close_while_parkedtest) so the suite skips cleanly rather than failing due to missing/unsupportedopenssl.
Test.SkipUnless(Condition.PluginExists('rate_limit.so'))
plugins/experimental/rate_limit/limiter.h:347
remove()does a linear scan and takeselemby value. If the queue can grow large, this makes closes O(n) and can become a hotspot under load. Consider (a) takingelemasconst T&to avoid copies, and (b) if large queues are expected, maintaining an auxiliary index (e.g., map from element to iterator) to make removals O(1); alternatively document/enforce a small maximum queue size to bound the cost.
bool
remove(T elem)
{
std::lock_guard<std::mutex> lock(_queue_lock);
for (auto it = _queue.begin(); it != _queue.end(); ++it) {
if (std::get<0>(*it) == elem) {
_queue.erase(it);
--_size;
return true;
}
}
return false;
}
plugins/experimental/rate_limit/sni_selector.cc:231
- When
pop()returnsnullptr, the code frees the reserved slot andbreaks out of the loop. If the queue becomes non-empty again immediately after (or ifsize()was stale due to concurrent modifications), the sweep won’t attempt to resume other queued VCs until the next sweep tick. Considercontinue-ing afterfree()(or re-checkingsize()under the same synchronization used bypop()) to make the loop more robust and reduce avoidable resume latency.
while (limiter->size() > 0 && limiter->reserve() == ReserveStatus::RESERVED) {
auto [vc, contp, start_time] = limiter->pop();
if (nullptr == vc) { // A concurrent close emptied the queue; give the slot back
limiter->free();
break;
}
Drop the dependency on coreutils "timeout", which is absent on macOS and made the gold tests fail rather than skip there, and which was relied on for fractional deadlines. A small sleep-and-kill helper replaces it. Also drop -verify_quiet, which is redundant with -quiet and is not accepted by every s_client implementation. Take the element by const reference in RateLimiter::remove(), and record what bounds the scan: the configured queue size, or connections_throttle when a "queue" is given without a "size". Correct the queue test's narration. It described the counter wrapping and the probe aborting the server, which is what happened before 508c1be fixed the sweep's resume condition; the test now pins that fix rather than reproducing it.
|
Both of my earlier points are addressed, and I verified them rather than taking the replies at face value. From my side this is clear. Global lock → per-limiter. Confirmed: the only lock acquisition this PR adds is Queue depth. The inline comment on While re-reading I went looking for a residual race and convinced myself it is closed, which is worth recording because it is the subtle part of the change. My worry was the check-then-act at the close site: if (!limiter->remove(vc)) {
limiter->free();
}
Reversing the sweep to reserve before dequeuing is what closes it, and the while (limiter->size() > 0 && limiter->reserve() == ReserveStatus::RESERVED) {
auto [vc, contp, start_time] = limiter->pop();
if (nullptr == vc) { // A concurrent close emptied the queue; give the slot back
limiter->free();
break;
}That makes the three cases add up: still queued, The I will leave the formal approval to a separate action so my |
…13406) * rate_limit: balance the SNI active-slot counter for queued connections A queued SNI connection never reserves a slot, but its VCONN_CLOSE released one unconditionally. A queued connection that closed therefore decremented the active-slot counter without a matching increment; it wrapped below zero and the next reserve() aborted the server on TSReleaseAssert(_active <= _limit). Balance the accounting: resume queued connections with reserve-then-pop so a resumed connection owns a real slot; release a slot on close only when the connection is no longer queued (a still-queued one never held one) and drop it from the queue; detach an expired connection the same way the reject path does. Removing a closing connection from the queue also fixes a stale-pointer dereference when a parked queued connection is reset. Add deterministic regressions for the resume and max_age paths. * rate_limit: add an SNI reject-teardown autest Exercise the sync-reject path against a TLS listener: a holder reserves the one slot and a burst of concurrent handshakes is rejected mid-handshake (TS_EVENT_ERROR) with the allocator freelists disabled. Asserts the reject path is reached and every rejected handshake VC is freed without a memory-safety fault. * rate_limit tests: annotate helpers and create the FIFO atomically Annotate the TestRun parameters like the surrounding class-based gold tests, and create the holder FIFO inside a fresh mktemp -d directory instead of on an unlinked mktemp -u path, whose creation is not atomic. * Deliver VCONN_CLOSE for connections parked in a TLS handshake hook callHooks() moves the hook state to DONE when a connection closes, but it kept curHook pointing into whichever handshake hook list the connection was parked in. Each hook id owns a separate list, so advancing curHook walked the handshake list rather than the close list: the close event was dropped once that list ran out, and delivered to the next handshake plugin when it did not. A plugin that parks a connection therefore never learns that it died. In the rate_limit SNI queue that leaves a freed TSVConn on the queue and leaks the selector lease, and the next sweep reenables freed memory. Restart from the head of the close hook list unless we are already iterating it. Take the same path for TS_EVENT_VCONN_OUTBOUND_CLOSE, which previously invoked nothing at all for a connection parked in the outbound pre-handshake hook. * rate_limit: address review feedback Drop the dependency on coreutils "timeout", which is absent on macOS and made the gold tests fail rather than skip there, and which was relied on for fractional deadlines. A small sleep-and-kill helper replaces it. Also drop -verify_quiet, which is redundant with -quiet and is not accepted by every s_client implementation. Take the element by const reference in RateLimiter::remove(), and record what bounds the scan: the configured queue size, or connections_throttle when a "queue" is given without a "size". Correct the queue test's narration. It described the counter wrapping and the probe aborting the server, which is what happened before 508c1be fixed the sweep's resume condition; the test now pins that fix rather than reproducing it. (cherry picked from commit b9b9109)
|
Cherry-picked to the 10.2.x branch as 6b00633 for the 10.2.0 release. |
Rebased onto master after 508c1be and 7c0dfb0 landed. Those already fixed the inverted sweep
loop condition and the
max_ageexpiry detach, so this PR no longer contains either. What remains isa core hook-dispatch bug and the queued-connection half of the rate_limit accounting, which depends
on it.
A connection that closes while parked in a TLS handshake hook never reaches the plugin's close
hook.
TLSEventSupport::callHooks()moves the hook state toDONEwhen a connection closes, but itkept
curHookpointing into whichever handshake hook list the connection was parked in. Each hook idowns a separate list, so advancing
curHookwalked the handshake list rather than the close list: theclose event was dropped once that list ran out, and delivered to the next handshake plugin when it did
not. The fix restarts from the head of the close list unless we are already iterating it, and routes
TS_EVENT_VCONN_OUTBOUND_CLOSEthrough the same path, which previously invoked nothing at all for aconnection parked in the outbound pre-handshake hook.
In the rate_limit SNI queue the consequence is a freed
TSVConnleft on the queue and a leakedselector lease, after which the next sweep reenables freed memory.
A connection that closes while still queued gives back a slot it never took. A connection parked
at the ClientHello hook holds no reservation; the sweep reserves the slot only when it resumes the
connection. The
VCONN_CLOSEhandler nonetheless calledlimiter->free()unconditionally, so a closein that state decremented
_activewith no matchingreserve(). Once the counter wraps, the nextreserve()tripsTSReleaseAssert(_active <= _limit)and the server aborts.RateLimiter::remove()now dequeues the connection and reports whether it was queued, and the handler frees a slot only when
it was not. Dequeuing also drops what would otherwise be a stale entry.
These land together because neither is complete alone: the plugin fix is unreachable until close
delivery works, and the core fix on its own makes the unmatched
free()reachable and reintroducesthe abort. The plugin commits come first so no bisect point has the core fix without its prerequisite.
The sweep takes no lock.
reserve(),free(),pop()andremove()are each internallysynchronized, and the one composite failure, a slot reserved when the queue turns out to be empty,
hands the slot straight back. An earlier revision of this PR serialized the sweep against close under
a plugin-global mutex; that is gone.
Tests.
tls_hooks_close_while_parkeddrives the core fix with the existingssl_hook_test.so,using a delayed ClientHello hook and a close hook plus a 1s handshake timeout that fires inside the 2s
park. Reverting the core fix makes it fail with
iterated to curHook=0x0and no close callback. Threerate_limit SNI autests cover queue-then-resume,
max_ageexpiry and reject teardown.rate_limit_sni_rejectis new coverage rather than a regression test for this change.rate_limit_sni_expiryoverlapsrate_limit_sni.test.pyin scenario, but asserts positively thatexpiry ran and reads
traffic.out, whereink_abortwrites.Not tested. The outbound close path is reasoned from the code, not exercised. No config-reload,
selector-teardown or concurrent-load coverage.
RateLimiter::remove()is an O(queue-depth) scan onevery rate-limited close, measured by microbenchmark at 3ns for an empty queue, 270ns at depth 100 and
2.2us at depth 1000, but not measured under load. The rate_limit autests need coreutils
timeoutandfail rather than skip without it.