Copilot commented on code in PR #13898:
URL: https://github.com/apache/trafficserver/pull/13898#discussion_r4447055747
##########
src/iocore/net/UnixNetAccept.cc:
##########
@@ -319,15 +442,31 @@ NetAccept::init_accept_per_thread()
NetAccept *a = (i < n - 1) ? clone() : this;
EThread *t = eventProcessor.thread_group[ET_NET]._thread[i];
a->mutex = get_NetHandler(t)->mutex;
+
+ a->uses_fast_accept = true;
t->schedule_imm(a);
}
+
+ return true;
}
void
NetAccept::stop_accept()
{
action_->cancel();
server.close();
+
+ // The retry timer dispatches into this acceptor, which the caller is about
to delete. Cancel it under
+ // the mutex that serializes acceptFastEvent. Per-thread clones are not
reachable from here; they notice the
+ // action is no longer listening and stand down on their own. Acceptors on a
dedicated
+ // accept thread never arm the timer, and their mutex is held by the accept
thread forever, so never wait on it.
+ if (uses_fast_accept && mutex != nullptr) {
+ SCOPED_MUTEX_LOCK(lock, mutex, this_ethread());
Review Comment:
This blocking NetHandler mutex acquisition can hang graceful shutdown when
its owning ET_NET thread is stuck in a plugin.
`SignalContinuation::state_running()` calls `stop_HttpProxyServer()` before
scheduling `AutoStopCont`, so the configured shutdown timeout is never
scheduled. Defer timer cancellation and acceptor reclamation to the owning
thread without blocking the signal continuation.
##########
src/iocore/net/UnixNetAccept.cc:
##########
@@ -375,9 +520,28 @@ NetAccept::do_blocking_accept(EThread *t)
int count = 0;
int additional_accepts = NetHandler::get_additional_accepts();
+ watchdog_apply_backlog(this);
+
+ if (watchdog_should_refuse(this)) {
+ // Dedicated accept thread - acceptLoopEvent calls straight back in, so
sleep rather than spin
+ // while the connections wait in the kernel accept queue.
+ Metrics::Counter::increment(net_rsb.watchdog_accepts_refused);
+ safe_delay(net_throttle_delay);
+ return 0;
Review Comment:
During SIGTERM, `UnixNetProcessor::stop_accept()` deletes the dedicated
acceptor even if its thread is sleeping here. On waking, returning 0 makes
`acceptLoopEvent()` call `do_blocking_accept()` again on the freed object. Keep
the acceptor alive until the worker observes cancellation and exits, with a
single owner responsible for deletion.
##########
include/iocore/eventsystem/WatchdogPolicy.h:
##########
@@ -0,0 +1,237 @@
+/** @file
+
+ The decision arithmetic of the watchdog's accept backpressure, free of
metrics, records and threads.
+
+ `Watchdog::Monitor` feeds each scan's peak awake span through advance() and
applies the result. Keeping
+ the arithmetic here lets it be exercised directly, without a running monitor.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#pragma once
+
+#include <algorithm>
+#include <chrono>
+#include <cstdint>
+
+namespace Watchdog
+{
+
+/// Most aggressive release deadband the retain knob will produce. See
release_threshold().
+constexpr int32_t min_release_permille = 500;
+
+/// The configuration one scan is decided against.
+struct PolicyConfig {
+ /// Whether proxy.config.exec_thread.watchdog.mode is backpressure. Nothing
engages otherwise.
+ bool backpressure = false;
+ /// proxy.config.exec_thread.watchdog.timeout_shrink_ms
+ std::chrono::milliseconds shrink_timeout{0};
+ /// proxy.config.exec_thread.watchdog.timeout_refuse_ms; zero disables
refuse.
+ std::chrono::milliseconds refuse_timeout{0};
+ /// proxy.config.exec_thread.watchdog.backlog_decrease_permille
+ int decrease_permille = 500;
+ /// proxy.config.exec_thread.watchdog.backlog_increase_permille
+ int increase_permille = 100;
+ /// proxy.config.exec_thread.watchdog.backlog_min_permille
+ int min_permille = 50;
+};
+
+/// What carries from one scan to the next.
+struct PolicyState {
+ /// Whether the accept queue shrink is engaged. Latched through the release
deadband.
+ bool shrink_engaged = false;
+ /// Whether the accept paths are refusing. Latched through the release
deadband.
+ bool refuse = false;
+ /// Listen backlog scale in permille. 1000 is unthrottled.
+ int scale = 1000;
+ /** Backlog scale in force when the current shrink episode began, in
permille; 0 when none.
+
+ The mark that headroom-attenuated recovery decelerates into. Cleared
once the scale is back to 1000.
+ */
+ int trouble_scale = 0;
+
+ bool operator==(PolicyState const &) const = default;
+};
+
+/// The outcome of one scan.
+struct PolicyResult {
+ PolicyState state;
+ /// The peak reached the shrink threshold, whatever the mode.
+ bool saturated = false;
+ /// The peak reached the refuse threshold, whatever the mode. Never set
while that threshold is zero.
+ bool refuse_exceeded = false;
+};
+
+/** Peak awake span at or below which a scan counts as recovered, given an
action @a threshold.
+
+ Every action engages at its own threshold but releases only here, so the
two differ and the
+ band between them holds the current state - a Schmitt trigger rather than
a bare comparison.
+ Without it a peak one millisecond under the threshold reads as full
recovery when it is really
+ the boundary, which is exactly where the state chatters and where
releasing lets the queue climb
+ back into the next step of load.
+
+ Derived from the decrease knob rather than from one of its own because it
is the same statement:
+ retain 500 means halve the queue when saturated *and* wait for the peak to
halve before believing
+ the recovery. Aggressive shedding and late release belong together.
+
+ Floored at @c min_release_permille so the coupling cannot make
backpressure effectively
+ unreleasable - at retain 1 the threshold would be two milliseconds on a
two second timeout,
+ which a merely busy server may never reach. Half the threshold is already
far outside
+ measurement noise, so nothing is lost by refusing to go deeper; below the
floor the knob keeps
+ sharpening the shed and stops affecting the release.
+ */
+inline std::chrono::nanoseconds
+release_threshold(std::chrono::milliseconds threshold, int decrease_permille)
+{
+ int32_t const retain = std::max<int32_t>(min_release_permille,
decrease_permille);
+ // Scaled in nanoseconds so a small timeout keeps its fractional-millisecond
boundary.
+ return std::chrono::duration_cast<std::chrono::nanoseconds>(threshold) *
retain / 1000;
+}
+
+/** @return Permille points to add to the backlog scale on a recovered scan.
+
+ @a increase_permille of the scale currently in force, attenuated by the
headroom remaining to
+ @c state.trouble_scale, or unattenuated when there is no mark to measure
against or the scale is
+ already above it.
+ */
+inline int
+recovery_step_permille(PolicyState const &state, int increase_permille)
+{
+ int const rate = increase_permille;
+
+ // Proportional to the depth currently in force, so the step is a small
number of connections while
+ // the queue is deep in the hole and grows as it climbs out - the absolute
count admitted per scan
+ // is what re-saturates the threads, and a flat step in points of the
baseline is the same count at
+ // every depth. The cap restates the invariant rather than binding: the
scale is at most 1000, so
+ // the proportional value is at most the configured points, which it reaches
only at full depth.
+ // The floor keeps the step positive against integer truncation - below a
scale of 1000/rate the
+ // product truncates to zero, and a zero step would leave the queue stranded
at that depth, because
+ // the caller only republishes when the value actually changes.
+ int const step = std::max(1, std::min(state.scale * rate / 1000, rate));
+
+ // No mark yet - the first recovery of the process, or one that already
climbed back to full depth.
+ // Nothing to attenuate against, so the proportional step stands.
+ if (state.trouble_scale <= 0) {
+ return step;
+ }
+
+ // Past the mark already - it has been proven survivable since, so it
carries no information about
+ // the rest of the climb. Full rate the rest of the way to 1000.
+ if (state.scale >= state.trouble_scale) {
+ return step;
+ }
+
+ // Within one full step of the mark: land exactly on it rather than tapering
into it. The attenuated
+ // step goes to zero as the gap closes, and the one point floor above would
otherwise turn the last
+ // stretch into a crawl of single-point scans - dozens of them at a large
backlog - for no benefit,
+ // since the mark is a depth to sit at and reassess, not one to creep up on.
Never returns more than
+ // @a step, because the branch condition is exactly that the remaining gap
fits inside one, and @a
+ // step is itself bounded by the configured rate - so making the step
proportional cannot turn this
+ // into an unbounded leap onto the mark.
+ if (state.scale + step >= state.trouble_scale) {
+ return state.trouble_scale - state.scale;
+ }
+
+ // Attenuate by the fraction of the way still to go, so the climb
decelerates as it approaches the
+ // depth that last caused trouble instead of arriving there at full speed.
Far below the mark this is
+ // ~1 and costs nothing. The floor keeps the step positive against integer
truncation; the snap above
+ // is what stops it from being reached repeatedly.
+ int const headroom = state.trouble_scale - state.scale;
+ return std::max(1, step * headroom / state.trouble_scale);
+}
+
+/** Decide one scan.
+
+ Each action engages on the first scan at or above its own threshold and
releases only once the
+ peak has fallen to the deadband below it. Between the two thresholds the
current state holds. Shrink
+ and refuse each have their own threshold on the same peak and neither
depends on the other.
+
+ @param[in] config The configuration in force for this scan.
+ @param[in] state The state the previous scan left.
+ @param[in] peak The largest awake span across all watched threads since
the previous scan.
+ */
+inline PolicyResult
+advance(PolicyConfig const &config, PolicyState state,
std::chrono::steady_clock::duration peak)
+{
+ PolicyResult result;
+
+ result.saturated = peak >= config.shrink_timeout;
+ bool const recovered = peak <= release_threshold(config.shrink_timeout,
config.decrease_permille);
+
+ // Shrink stage state. Mode off releases now rather than waiting out the
deadband - the point of
+ // turning it off is to stop throttling immediately.
+ bool const was_shrinking = state.shrink_engaged;
+ if (!config.backpressure) {
+ state.shrink_engaged = false;
+ } else if (result.saturated) {
+ state.shrink_engaged = true;
+ } else if (recovered) {
+ state.shrink_engaged = false;
+ }
+
+ // The depth still in force as this episode begins is, by construction, a
depth that let in more
+ // than the threads could serve. Remember it as the mark that
headroom-attenuated recovery slows
+ // down against. Captured before the decrease below touches the scale.
+ if (state.shrink_engaged && !was_shrinking) {
+ state.trouble_scale = state.scale;
+ }
+
+ // Accept queue sizing. Multiplicative decrease on a saturated scan,
proportional increase once
+ // released, and held steady inside the release deadband - a peak that has
come down but not far
+ // enough is not evidence for shedding harder, and without the saturated
term a long stay in the
+ // deadband would squeeze the queue toward the floor while it was recovering.
+ if (config.backpressure) {
+ int want = state.scale;
+
+ if (state.shrink_engaged && result.saturated) {
+ // Integer truncation would stall the decrease entirely at small scales
- 3 permille retained
+ // at 900 rounds back to 3 - so always give up at least one point while
shrinking.
+ int const next = std::min(state.scale * config.decrease_permille / 1000,
state.scale - 1);
+ want = std::max(config.min_permille, next);
+ } else if (!state.shrink_engaged) {
+ want = std::min(1000, state.scale + recovery_step_permille(state,
config.increase_permille));
+ }
+ state.scale = want;
Review Comment:
The reloadable backlog floor is enforced only on a saturated shrink scan.
Raising `backlog_min_permille` from 50 to 500 while the peak remains in the
release deadband leaves the scale at 50 indefinitely, and recovery scans can
also publish values below the new floor. Clamp the resulting scale to the
current minimum on every backpressure scan.
##########
src/proxy/http/HttpProxyServerMain.cc:
##########
@@ -345,12 +345,95 @@ init_HttpProxyServer()
#endif
}
+namespace
+{
+
+/** Open a loopback-bound companion listener for @a port, exempt from watchdog
backpressure.
+ *
+ * Local connections arrive on the same port as all other traffic, so there is
no port to exempt -
+ * only a source address. But watchdog backpressure is a decision about a
+ * listen socket, taken before @c accept4, and a connection's peer address is
not knowable until after
+ * it. The way out is to let the kernel do the discrimination: a socket bound
explicitly to the
+ * loopback address takes precedence over the wildcard socket on the same
port, so local connections
+ * land on this listener and everything from off box lands on the original
one. Marking only this
+ * listener exempt then protects local connections without giving up the
backpressure for the flood.
+ *
+ * Opened whenever the watchdog is enabled at all, rather than when an active
mode is selected:
+ * `proxy.config.exec_thread.watchdog.mode` is reloadable, and binding or
unbinding sockets in
+ * response to a config change would be a great deal of risk for no benefit.
An exempt loopback
+ * listener behaves identically to the wildcard one when no backpressure is
engaged.
+ *
+ * @param[in] acceptor The acceptor whose continuation and options this
listener shares.
+ * @param[in] port The proxy port being duplicated on loopback.
+ */
+void
+start_loopback_companion_acceptor(HttpProxyAcceptor &acceptor, HttpProxyPort
&port)
+{
+ NetProcessor::AcceptOptions net_opt = acceptor._net_opt;
+
+ // localhost_only takes precedence over local_ip in accept_internal(), which
is what binds this to
+ // 127.0.0.1 or ::1 according to the port's address family.
+ net_opt.localhost_only = true;
+ net_opt.f_watchdog_exempt = true;
+
+ // Overlapping binds - this loopback socket and the port's wildcard socket -
require SO_REUSEPORT on
+ // both. The caller has already set it on the wildcard side.
+ net_opt.f_force_reuseport = true;
+
+ // Never take the proxy down over a supplementary listener. Without it the
exemption is simply not
+ // in effect, which the warning from Server::listen() reports.
+ net_opt.f_non_fatal_listen = true;
+
+ // Don't double the process's dedicated accept threads for a listener that
will see only a low
+ // volume of local connections. Left alone when accept threads are not in
use, so that the companion follows
+ // the same per-thread or SO_REUSEPORT model as the port it shadows.
+ int accept_threads = net_opt.accept_threads;
+ if (accept_threads < 0) {
+ accept_threads =
RecGetRecordInt("proxy.config.accept_threads").value_or(0);
+ }
+ if (accept_threads > 0) {
+ net_opt.accept_threads = 1;
+ }
Review Comment:
With dedicated accept threads, the companion dispatches local requests
through `eventProcessor.assign_thread(ET_NET)`, which selects workers
round-robin without checking whether they are stalled. A localhost health check
can therefore wait for the full stall while other event threads are idle. Use
the shared ET_NET accept path for companions even when the wildcard listener
uses dedicated accept threads.
##########
src/iocore/net/UnixNetAccept.cc:
##########
@@ -375,9 +520,28 @@ NetAccept::do_blocking_accept(EThread *t)
int count = 0;
int additional_accepts = NetHandler::get_additional_accepts();
+ watchdog_apply_backlog(this);
+
+ if (watchdog_should_refuse(this)) {
+ // Dedicated accept thread - acceptLoopEvent calls straight back in, so
sleep rather than spin
+ // while the connections wait in the kernel accept queue.
+ Metrics::Counter::increment(net_rsb.watchdog_accepts_refused);
+ safe_delay(net_throttle_delay);
Review Comment:
With dedicated accept threads, refusal cannot interrupt a blocking
`server.accept()`, so the next connection is still accepted and dispatched if
refusal engages while that call is waiting. This also breaks the new test's
unchanged `total_accepts` assertion for `accept_threads=1`. Use a nonblocking
listener with a bounded or explicitly wakeable readiness wait, and recheck
refusal before accepting.
##########
src/iocore/net/UnixNetAccept.cc:
##########
@@ -529,7 +693,52 @@ NetAccept::acceptFastEvent(int event, void *ep)
NetHandler *h = get_NetHandler(t);
int additional_accepts =
NetHandler::get_additional_accepts();
+ if (e == refuse_retry) {
+ refuse_retry = nullptr;
+ }
+
+ // Clones share action_ with the acceptor that stop_accept() closed, so this
is how they learn the listen fd is gone.
+ // It may already belong to another connection, so neither touch it nor arm
another retry.
+ if (!action_->is_listening()) {
+ if (refuse_retry != nullptr) {
+ refuse_retry->cancel(this);
+ refuse_retry = nullptr;
+ }
+ return EVENT_DONE;
+ }
+
+ watchdog_apply_backlog(this);
+
+ // See the equivalent comment in net_accept(). The listen fd here is
registered edge triggered, so
+ // refusing must come back on a timer: returning without draining would not
produce another
+ // readiness event for the connections already queued. New arrivals still
wake us while refusing,
+ // so only arm the timer if one is not already pending.
+ if (watchdog_should_refuse(this)) {
+ Metrics::Counter::increment(net_rsb.watchdog_accepts_refused);
+ if (refuse_retry == nullptr && action_->is_listening()) {
+ refuse_retry = t->schedule_in(this, HRTIME_MSECONDS(net_throttle_delay));
+ }
+ return EVENT_CONT;
+ }
+
+ // An arrival beat the retry timer to it, so this pass does the drain the
timer was armed for.
+ if (refuse_retry != nullptr) {
+ refuse_retry->cancel(this);
+ refuse_retry = nullptr;
+ }
+
do {
+ if (count > 0) {
+ watchdog_apply_backlog(this);
+ if (watchdog_should_refuse(this)) {
+ Metrics::Counter::increment(net_rsb.watchdog_accepts_refused);
+ if (refuse_retry == nullptr && action_->is_listening()) {
+ refuse_retry = t->schedule_in(this,
HRTIME_MSECONDS(net_throttle_delay));
+ }
+ goto Ldone;
+ }
+ }
Review Comment:
Connections discarded by the connection limits do not increment `count`, so
a continuous stream of rejected connections can keep this loop running without
ever checking newly engaged watchdog backpressure. Check on every iteration so
shedding switches from accepting and closing connections to leaving them queued
in the kernel. Apply the same change in `net_accept()` at line 207 and
`do_blocking_accept()` at line 536.
##########
src/proxy/http/HttpProxyServerMain.cc:
##########
@@ -361,6 +444,14 @@ start_HttpProxyServer()
for (int i = 0, n = proxy_ports.size(); i < n; ++i) {
HttpProxyAcceptor &acceptor = HttpProxyAcceptors[i];
HttpProxyPort &port = proxy_ports[i];
+
+ // Decided before the port is opened, because an overlapping bind needs
SO_REUSEPORT on *both*
+ // sockets and it can only be set before bind().
+ bool const companion = watchdog_shrink_timeout_ms > 0 &&
wants_loopback_companion(port);
+ if (companion) {
+ acceptor._net_opt.f_force_reuseport = true;
Review Comment:
An explicitly loopback-bound proxy port, such as `8080:ip-in=127.0.0.1`,
gets neither a companion nor `f_watchdog_exempt`. Backpressure therefore
shrinks and refuses that localhost listener, leaving local health checks
unprotected. Exempt existing loopback-bound listeners without opening another
socket, while handling the flag's effect on per-thread listener topology
explicitly.
##########
src/iocore/net/UnixNetAccept.cc:
##########
@@ -529,7 +693,52 @@ NetAccept::acceptFastEvent(int event, void *ep)
NetHandler *h = get_NetHandler(t);
int additional_accepts =
NetHandler::get_additional_accepts();
+ if (e == refuse_retry) {
+ refuse_retry = nullptr;
+ }
+
+ // Clones share action_ with the acceptor that stop_accept() closed, so this
is how they learn the listen fd is gone.
+ // It may already belong to another connection, so neither touch it nor arm
another retry.
+ if (!action_->is_listening()) {
+ if (refuse_retry != nullptr) {
+ refuse_retry->cancel(this);
+ refuse_retry = nullptr;
+ }
+ return EVENT_DONE;
Review Comment:
With `proxy.config.exec_thread.listen=1`, clones own independent listening
sockets, but cancelling their shared action closes only the original acceptor's
socket. Returning here leaves the clones' sockets and EventIO registrations
open during the drain, so clients can complete TCP handshakes but are never
accepted. Arrange owner-thread cleanup of each independently owned listener
before standing down.
--
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]