Skip to content

Commit

Permalink
Merge pull request #505 from VladimirKuzmin/reuse-port
Browse files Browse the repository at this point in the history
Make it possible to set SO_REUSEPORT to the server's socket
  • Loading branch information
jaraco authored May 20, 2023
2 parents 580e296 + 081aa09 commit 2b3b3eb
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 4 deletions.
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
.. scm-version-title:: v10.0.0

- :issue:`504` via :pr:`505`: Cheroot now accepts a
``reuse_port`` parameter on the ``HTTPServer`` object.
Subclasses overriding ``prepare_socket`` will no longer
work and will need to adapt to the new interface.

.. scm-version-title:: v9.0.0

- :issue:`252` via :pr:`339`: Cheroot now requires Python
Expand Down
43 changes: 42 additions & 1 deletion cheroot/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,9 @@ class HTTPServer:
``PEERCREDS``-provided IDs.
"""

reuse_port = False
"""If True, set SO_REUSEPORT on the socket."""

keep_alive_conn_limit = 10
"""Maximum number of waiting keep-alive connections that will be kept open.
Expand All @@ -1581,6 +1584,7 @@ def __init__(
self, bind_addr, gateway,
minthreads=10, maxthreads=-1, server_name=None,
peercreds_enabled=False, peercreds_resolve_enabled=False,
reuse_port=False,
):
"""Initialize HTTPServer instance.
Expand All @@ -1591,6 +1595,8 @@ def __init__(
maxthreads (int): maximum number of threads for HTTP thread pool
server_name (str): web server name to be advertised via Server
HTTP header
reuse_port (bool): if True SO_REUSEPORT option would be set to
socket
"""
self.bind_addr = bind_addr
self.gateway = gateway
Expand All @@ -1606,6 +1612,7 @@ def __init__(
self.peercreds_resolve_enabled = (
peercreds_resolve_enabled and peercreds_enabled
)
self.reuse_port = reuse_port
self.clear_stats()

def clear_stats(self):
Expand Down Expand Up @@ -1880,6 +1887,7 @@ def bind(self, family, type, proto=0):
self.bind_addr,
family, type, proto,
self.nodelay, self.ssl_adapter,
self.reuse_port,
)
sock = self.socket = self.bind_socket(sock, self.bind_addr)
self.bind_addr = self.resolve_real_bind_addr(sock)
Expand Down Expand Up @@ -1928,6 +1936,7 @@ def bind_unix_socket(self, bind_addr): # noqa: C901 # FIXME
bind_addr=bind_addr,
family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0,
nodelay=self.nodelay, ssl_adapter=self.ssl_adapter,
reuse_port=self.reuse_port,
)

try:
Expand Down Expand Up @@ -1968,14 +1977,46 @@ def bind_unix_socket(self, bind_addr): # noqa: C901 # FIXME
return sock

@staticmethod
def prepare_socket(bind_addr, family, type, proto, nodelay, ssl_adapter):
def _make_socket_reusable(socket_, bind_addr):
host, port = bind_addr[:2]
IS_EPHEMERAL_PORT = port == 0

if socket_.family not in (socket.AF_INET, socket.AF_INET6):
raise ValueError('Cannot reuse a non-IP socket')

if IS_EPHEMERAL_PORT:
raise ValueError('Cannot reuse an ephemeral port (0)')

# Most BSD kernels implement SO_REUSEPORT the way that only the
# latest listener can read from socket. Some of BSD kernels also
# have SO_REUSEPORT_LB that works similarly to SO_REUSEPORT
# in Linux.
if hasattr(socket, 'SO_REUSEPORT_LB'):
socket_.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT_LB, 1)
elif hasattr(socket, 'SO_REUSEPORT'):
socket_.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
elif IS_WINDOWS:
socket_.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
else:
raise NotImplementedError(
'Current platform does not support port reuse',
)

@classmethod
def prepare_socket(
cls, bind_addr, family, type, proto, nodelay, ssl_adapter,
reuse_port=False,
):
"""Create and prepare the socket object."""
sock = socket.socket(family, type, proto)
connections.prevent_socket_inheritance(sock)

host, port = bind_addr[:2]
IS_EPHEMERAL_PORT = port == 0

if reuse_port:
cls._make_socket_reusable(socket_=sock, bind_addr=bind_addr)

if not (IS_WINDOWS or IS_EPHEMERAL_PORT):
"""Enable SO_REUSEADDR for the current socket.
Expand Down
7 changes: 5 additions & 2 deletions cheroot/server.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,10 @@ class HTTPServer:
ssl_adapter: Any
peercreds_enabled: bool
peercreds_resolve_enabled: bool
reuse_port: bool
keep_alive_conn_limit: int
requests: Any
def __init__(self, bind_addr, gateway, minthreads: int = ..., maxthreads: int = ..., server_name: Any | None = ..., peercreds_enabled: bool = ..., peercreds_resolve_enabled: bool = ...) -> None: ...
def __init__(self, bind_addr, gateway, minthreads: int = ..., maxthreads: int = ..., server_name: Any | None = ..., peercreds_enabled: bool = ..., peercreds_resolve_enabled: bool = ..., reuse_port: bool = ...) -> None: ...
stats: Any
def clear_stats(self): ...
def runtime(self): ...
Expand All @@ -152,7 +153,9 @@ class HTTPServer:
def bind(self, family, type, proto: int = ...): ...
def bind_unix_socket(self, bind_addr): ...
@staticmethod
def prepare_socket(bind_addr, family, type, proto, nodelay, ssl_adapter): ...
def _make_socket_reusable(socket_, bind_addr) -> None: ...
@classmethod
def prepare_socket(cls, bind_addr, family, type, proto, nodelay, ssl_adapter, reuse_port: bool = ...): ...
@staticmethod
def bind_socket(socket_, bind_addr): ...
@staticmethod
Expand Down
27 changes: 27 additions & 0 deletions cheroot/test/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,33 @@ def native_process_conn(conn):
assert any(fn >= resource_limit for fn in native_process_conn.filenos)


@pytest.mark.skipif(
not hasattr(socket, 'SO_REUSEPORT'),
reason='socket.SO_REUSEPORT is not supported on this platform',
)
@pytest.mark.parametrize(
'ip_addr',
(
ANY_INTERFACE_IPV4,
ANY_INTERFACE_IPV6,
),
)
def test_reuse_port(http_server, ip_addr, mocker):
"""Check that port initialized externally can be reused."""
family = socket.getaddrinfo(ip_addr, EPHEMERAL_PORT)[0][0]
s = socket.socket(family)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.bind((ip_addr, EPHEMERAL_PORT))
server = HTTPServer(
bind_addr=s.getsockname()[:2], gateway=Gateway, reuse_port=True,
)
spy = mocker.spy(server, 'prepare')
server.prepare()
server.stop()
s.close()
assert spy.spy_exception is None


ISSUE511 = IS_MACOS


Expand Down
2 changes: 2 additions & 0 deletions cheroot/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def __init__(
max=-1, request_queue_size=5, timeout=10, shutdown_timeout=5,
accepted_queue_size=-1, accepted_queue_timeout=10,
peercreds_enabled=False, peercreds_resolve_enabled=False,
reuse_port=False,
):
"""Initialize WSGI Server instance.
Expand All @@ -69,6 +70,7 @@ def __init__(
server_name=server_name,
peercreds_enabled=peercreds_enabled,
peercreds_resolve_enabled=peercreds_resolve_enabled,
reuse_port=reuse_port,
)
self.wsgi_app = wsgi_app
self.request_queue_size = request_queue_size
Expand Down
2 changes: 1 addition & 1 deletion cheroot/wsgi.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class Server(server.HTTPServer):
timeout: Any
shutdown_timeout: Any
requests: Any
def __init__(self, bind_addr, wsgi_app, numthreads: int = ..., server_name: Any | None = ..., max: int = ..., request_queue_size: int = ..., timeout: int = ..., shutdown_timeout: int = ..., accepted_queue_size: int = ..., accepted_queue_timeout: int = ..., peercreds_enabled: bool = ..., peercreds_resolve_enabled: bool = ...) -> None: ...
def __init__(self, bind_addr, wsgi_app, numthreads: int = ..., server_name: Any | None = ..., max: int = ..., request_queue_size: int = ..., timeout: int = ..., shutdown_timeout: int = ..., accepted_queue_size: int = ..., accepted_queue_timeout: int = ..., peercreds_enabled: bool = ..., peercreds_resolve_enabled: bool = ..., reuse_port: bool = ...) -> None: ...
@property
def numthreads(self): ...
@numthreads.setter
Expand Down

0 comments on commit 2b3b3eb

Please sign in to comment.