diff --git a/AGENTS.md b/AGENTS.md index 864c6e3..5a5e016 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Each raw message parses into a generic `_Message`. The reader then looks up the ### FastAGI scaling -This fork's main feature is FastAGI throughput. `_ThreadedTCPServer` (`fastagi.py:52`) sizes `request_queue_size` from the system `SOMAXCONN` (read via `sysctl` on Linux and Darwin) instead of the small Python default, and sets `allow_reuse_address`. The listen backlog directly bounds how many simultaneous calls the server can accept under a surge. +This fork's main feature is FastAGI throughput. `_ThreadedTCPServer` (`fastagi.py`) sets `request_queue_size` to `_LISTEN_BACKLOG` (`INT_MAX`) instead of the small Python default, and sets `allow_reuse_address`. The listen backlog directly bounds how many simultaneous calls the server can accept under a surge. Rather than read the system limit, it passes the maximum and lets the OS cap the backlog. Unix-like kernels clamp it to the live `somaxconn` (`net.core.somaxconn` on Linux, `kern.ipc.somaxconn` on macOS/BSD), which tracks a tuned-up limit automatically; Windows applies the Winsock maximum (its `SOMAXCONN` is itself `INT_MAX`). This runs on every platform and avoids the old `sysctl` subprocess that raised on non-Linux/macOS hosts. See the `_LISTEN_BACKLOG` comment for the full rationale. ## Conventions for extending the library diff --git a/CHANGELOG.md b/CHANGELOG.md index dc12401..0c0fd4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `Manager.monitor_connection` no longer crashes its monitoring thread when the connection drops. Pinging a downed connection raised `ManagerSocketError` (broken socket) or `ManagerError` (the liveness check inside `send_action` failing when the connection dropped just after the loop's own check) inside the thread, which dumped a traceback to stderr and killed the monitor. The monitor now catches both and stops cleanly, logging the reason at debug level when a logger is set. The method also returns the monitoring thread so callers can join it (#3). - `Manager.send_action` no longer raises a raw `AttributeError` when a concurrent `disconnect()` clears the connection between the liveness check and the send. It now drops the just-registered request and raises `ManagerSocketError` instead (#3). - The FastAGI server no longer prints an unhandled traceback to stderr when a client disconnects during the AGI environment handshake. A caller hanging up, Asterisk aborting the leg, or a bare TCP probe raised `AGISIGPIPEHangup` from the handler and printed a full traceback for a routine event. The handler now ends the request quietly. Errors raised by the script handler itself still propagate (#49). +- The FastAGI server sizes its listen backlog without shelling out to `sysctl`. It now requests the maximum backlog and lets the operating system cap it: Unix-like kernels clamp it to the live `somaxconn`, which still tracks a tuned-up limit automatically, and Windows applies the Winsock maximum. This also lifts the platform restriction added in 1.3.0: the server previously raised `NotImplementedError` on any host that was neither Linux nor macOS, and now runs everywhere (#32). ## [1.3.0] - 2026-06-24 diff --git a/README.md b/README.md index 5c839c2..877cd78 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ server.register_script_handler(None, demo_handler) # default handler server.serve_forever() ``` -The FastAGI server sizes its listen backlog from the system `SOMAXCONN` value, so it absorbs large bursts of simultaneous calls. It reads that value with `sysctl`, so the server currently runs on Linux and macOS only. AMI and AGI have no such restriction. +The FastAGI server requests the largest listen backlog the socket layer accepts and lets the operating system cap it, so it absorbs large bursts of simultaneous calls. On Unix-like systems the kernel clamps it to the host's configured maximum (`net.core.somaxconn` on Linux, `kern.ipc.somaxconn` on macOS and the BSDs), so it automatically tracks a tuned-up limit; on Windows, Winsock applies its own maximum. This needs no configuration and runs on any platform. ### AMI — control and monitor the server diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 8758c99..45870ef 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -36,16 +36,43 @@ - Neil Tallim """ -import platform -import socket import socketserver -import subprocess import threading from urllib.parse import parse_qs from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI +# The listen backlog bounds how many connections may queue while the server is +# busy, which directly limits the call surge a FastAGI server can absorb. We +# want the largest backlog the kernel will allow on the host. +# +# We do NOT read that limit ourselves. The kernel already enforces it: listen() +# silently caps the backlog to the live system maximum -- net.core.somaxconn on +# Linux, kern.ipc.somaxconn on macOS and the BSDs -- so passing a value at or +# above that maximum yields exactly the system maximum. Passing the largest +# value the call accepts therefore tracks a tuned-up maximum automatically, with +# no per-call lookup. +# +# This is why we do not use socket.SOMAXCONN (a small compile-time constant, +# historically 128 and 4096 since Linux 5.4) and do not hardcode a ceiling like +# 65535: on modern kernels somaxconn is a 32-bit value that an administrator can +# tune above 65535, so any fixed ceiling could undershoot. INT_MAX cannot, since +# the kernel's own limit is then the only cap. It also replaces the previous +# approach of shelling out to `sysctl`, which ran a subprocess, parsed +# OS-specific output, and raised on any system that was neither Linux nor macOS. +# +# The value must be exactly INT_MAX (2**31 - 1), not larger: CPython parses the +# listen() backlog into a C int and raises OverflowError above INT_MAX, which +# would crash server startup. Do not "round up" this constant. +# +# Windows works too, but by a different route: it does not clamp to a system +# somaxconn. Winsock's own SOMAXCONN constant is 0x7fffffff (= INT_MAX), and +# Winsock treats that exact value as a sentinel meaning "use a maximum +# reasonable backlog". So INT_MAX is the right value on every platform, though +# Windows honors it as that sentinel rather than as a tuned registry limit. +_LISTEN_BACKLOG = 2**31 - 1 # INT_MAX; Unix kernels cap this to the live somaxconn + class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): """ @@ -53,35 +80,10 @@ class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): request. """ - @staticmethod - def get_somaxconn(): - """ - Returns the value of SOMAXCONN configured in the system. - """ - # determine the OS appropriate management informations base (MIB) - # name to determine SOMAXCONN - system = platform.system() - if "Linux" == system: - sysctl_mib_somaxconn = "net.core.somaxconn" - sysctl_output_delimiter = "=" - elif "Darwin" == system: - sysctl_mib_somaxconn = "kern.ipc.somaxconn" - sysctl_output_delimiter = ":" - else: - raise NotImplementedError( - "Determining SOMAXCONN is not implemented for {} system.".format(system) - ) - # run the cmd to determine the SOMAXCONN - cmd_result = subprocess.check_output(["sysctl", sysctl_mib_somaxconn]) - - # parse the output of the cmd to return the value of SOMAXCONN - return int(cmd_result.decode().split(sysctl_output_delimiter)[-1].strip()) - def __init__(self, *args, **kwargs): - # adjust request queue size to a saner value for modern systems - # further adjustments are automatically picked up for kernel - # settings on server start - self.request_queue_size = max(socket.SOMAXCONN, self.get_somaxconn()) + # Request the maximum backlog and let the kernel cap it to the live + # system somaxconn; see _LISTEN_BACKLOG for the full rationale. + self.request_queue_size = _LISTEN_BACKLOG self.allow_reuse_address = True super().__init__(*args, **kwargs) diff --git a/tests/test_fastagi_server.py b/tests/test_fastagi_server.py new file mode 100644 index 0000000..3f77c02 --- /dev/null +++ b/tests/test_fastagi_server.py @@ -0,0 +1,29 @@ +"""Tests for FastAGIServer listen-backlog configuration.""" + +from pystrix.agi import fastagi +from pystrix.agi.fastagi import _LISTEN_BACKLOG, FastAGIServer, _ThreadedTCPServer + + +def test_server_requests_max_listen_backlog(): + # Regression for #32: the server asks for the largest backlog the socket + # call accepts and lets the kernel cap it to the live somaxconn. Binding on + # an ephemeral port also proves the platform's listen() accepts the value + # rather than rejecting it. + server = FastAGIServer(interface="127.0.0.1", port=0) + try: + assert server.request_queue_size == _LISTEN_BACKLOG + # Lock the exact contract: the backlog must be INT_MAX, the largest value + # CPython's listen() accepts (2**31 raises OverflowError). A smaller fixed + # ceiling would also undershoot a tuned-up somaxconn. + assert _LISTEN_BACKLOG == 2**31 - 1 + finally: + server.server_close() + + +def test_backlog_sizing_does_not_shell_out(): + # The sysctl-spawning, OS-branching helper is gone and the module no longer + # imports the subprocess/platform machinery it used, so backlog sizing cannot + # shell out or raise on platforms without sysctl (regression for #32). + assert not hasattr(_ThreadedTCPServer, "get_somaxconn") + assert not hasattr(fastagi, "subprocess") + assert not hasattr(fastagi, "platform")