Skip to content

Commit bb698cf

Browse files
authored
Merge pull request #794 from itamarst/769-thread-leak
Fix thread leak by switching to different `Queue` shutdown mechanism
2 parents 22bb430 + 8d1e5a0 commit bb698cf

File tree

7 files changed

+75
-37
lines changed

7 files changed

+75
-37
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ repos:
148148
- id: name-tests-test
149149
args:
150150
- --django
151-
exclude: cheroot/test/(helper|webtest|_pytest_plugin).py
151+
exclude: cheroot/test/(helper|webtest|_pytest_plugin|threadleakcheck).py
152152
files: cheroot/test/.+\.py$
153153
- id: check-added-large-files
154154
- id: check-case-conflict

cheroot/server.py

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -100,37 +100,6 @@
100100
'get_ssl_adapter_class',
101101
)
102102

103-
104-
if sys.version_info[:2] >= (3, 13):
105-
from queue import (
106-
Queue as QueueWithShutdown,
107-
ShutDown as QueueShutDown,
108-
)
109-
else:
110-
111-
class QueueShutDown(Exception):
112-
"""Queue has been shut down."""
113-
114-
class QueueWithShutdown(queue.Queue):
115-
"""Add shutdown() similar to Python 3.13+ Queue."""
116-
117-
_queue_shut_down: bool = False
118-
119-
def shutdown(self, immediate=False):
120-
if immediate:
121-
while True:
122-
try:
123-
self.get_nowait()
124-
except queue.Empty:
125-
break
126-
self._queue_shut_down = True
127-
128-
def get(self, *args, **kwargs):
129-
if self._queue_shut_down:
130-
raise QueueShutDown
131-
return super().get(*args, **kwargs)
132-
133-
134103
IS_WINDOWS = platform.system() == 'Windows'
135104
"""Flag indicating whether the app is running under Windows."""
136105

@@ -1691,7 +1660,7 @@ def __init__( # pylint: disable=too-many-positional-arguments
16911660
self.reuse_port = reuse_port
16921661
self.clear_stats()
16931662

1694-
self._unservicable_conns = QueueWithShutdown()
1663+
self._unservicable_conns = queue.Queue()
16951664

16961665
def clear_stats(self):
16971666
"""Reset server stat counters.."""
@@ -1904,9 +1873,8 @@ def prepare(self): # noqa: C901 # FIXME
19041873
def _serve_unservicable(self):
19051874
"""Serve connections we can't handle a 503."""
19061875
while self.ready:
1907-
try:
1908-
conn = self._unservicable_conns.get()
1909-
except QueueShutDown:
1876+
conn = self._unservicable_conns.get()
1877+
if conn is _STOPPING_FOR_INTERRUPT:
19101878
return
19111879
request = HTTPRequest(self, conn)
19121880
try:
@@ -2269,7 +2237,7 @@ def stop(self): # noqa: C901 # FIXME
22692237

22702238
# This tells the thread that handles unservicable connections to shut
22712239
# down:
2272-
self._unservicable_conns.shutdown(immediate=True)
2240+
self._unservicable_conns.put(_STOPPING_FOR_INTERRUPT)
22732241

22742242
if self._start_time is not None:
22752243
self._run_time += time.time() - self._start_time

cheroot/test/test_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Tests for the HTTP server."""
22

33
import os
4+
import pathlib
45
import queue
56
import socket
7+
import subprocess
68
import sys
79
import tempfile
810
import threading
@@ -23,6 +25,7 @@
2325
ANY_INTERFACE_IPV4,
2426
ANY_INTERFACE_IPV6,
2527
EPHEMERAL_PORT,
28+
SUCCESSFUL_SUBPROCESS_EXIT,
2629
)
2730
from ..workers.threadpool import ThreadPool
2831

@@ -611,3 +614,15 @@ def test_overload_results_in_suitable_http_error(request):
611614

612615
response = requests.get(f'http://{localhost}:{port}', timeout=20)
613616
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
617+
618+
619+
def test_overload_thread_does_not_leak():
620+
"""On shutdown the overload thread exits.
621+
622+
This is a test for issue #769.
623+
"""
624+
path = pathlib.Path(__file__).parent / 'threadleakcheck.py'
625+
process = subprocess.run([sys.executable, path], check=False)
626+
# We use special exit code to indicate success, rather than normal zero, so
627+
# the test doesn't acidentally pass:
628+
assert process.returncode == SUCCESSFUL_SUBPROCESS_EXIT

cheroot/test/threadleakcheck.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Make sure threads don't leak.
3+
4+
Run in an isolated subprocess by ``test_server.py``, to ensure parallelism of
5+
any sort don't cause problems.
6+
"""
7+
8+
import sys
9+
import threading
10+
import time
11+
12+
from cheroot.server import Gateway, HTTPServer
13+
from cheroot.testing import (
14+
ANY_INTERFACE_IPV4,
15+
EPHEMERAL_PORT,
16+
SUCCESSFUL_SUBPROCESS_EXIT,
17+
)
18+
19+
20+
SLEEP_INTERVAL = 0.2
21+
22+
23+
def check_for_leaks():
24+
"""Exit with special success code if no threads were leaked."""
25+
before_serv = threading.active_count()
26+
for _ in range(5):
27+
httpserver = HTTPServer(
28+
bind_addr=(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
29+
gateway=Gateway,
30+
)
31+
with httpserver._run_in_thread():
32+
time.sleep(SLEEP_INTERVAL)
33+
34+
leaked_threads = threading.active_count() - before_serv
35+
if leaked_threads == 0:
36+
sys.exit(SUCCESSFUL_SUBPROCESS_EXIT)
37+
else:
38+
# We leaked a thread:
39+
sys.exit(f'Number of leaked threads: {leaked_threads}')
40+
41+
42+
if __name__ == '__main__':
43+
check_for_leaks()

cheroot/testing.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
ANY_INTERFACE_IPV4 = '0.0.0.0'
2020
ANY_INTERFACE_IPV6 = '::'
2121

22+
# We use special exit code to indicate success, rather than normal zero, so
23+
# the test doesn't acidentally pass:
24+
SUCCESSFUL_SUBPROCESS_EXIT = 23
25+
2226
config = {
2327
cheroot.wsgi.Server: {
2428
'bind_addr': (NO_INTERFACE, EPHEMERAL_PORT),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
794.bugfix.rst
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
The "service unavailable" thread is now turn down properly when
2+
the server is shut down -- by :user:`itamarst`.
3+
4+
This fixes a regression in Cheroot originally introduced in v11.0.0
5+
that would manifest itself under Python 3.12 and older. In certain
6+
conditions like under CherryPy, it would also lead to hangs on
7+
tear-down.

0 commit comments

Comments
 (0)