diff --git a/CHANGELOG.md b/CHANGELOG.md index fadc44e8..55fac12c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Celery Redis connections using unpatched sockets under gevent: `dispatcharr/__init__.py` imports `celery.py` as part of `django.setup()`, initialising the Redis connection pool before uWSGI's gevent plugin patches stdlib sockets. Added `gevent.monkey.patch_all()` at the top of `wsgi.py` to guarantee all broker connections are gevent-aware. - Thanks [@endoze](https://github.com/endoze) - TS proxy streams dying after 30–200 seconds in multi-worker uWSGI/Celery deployments, caused by three interrelated bugs. (Fixes #992, #980) - Thanks [@PFalko](https://github.com/PFalko) - **Double ProxyServer instantiation**: `ProxyConfig.ready()` called `TSProxyServer()` directly while `TSProxyConfig.ready()` also called `TSProxyServer.get_instance()`, creating two instances per worker — each with its own cleanup thread. The orphaned thread could not extend ownership because it had no entries in `stream_managers`. Fixed by using `TSProxyServer.get_instance()` in `ProxyConfig.ready()`. - **`flushdb()` on every Redis client init**: `RedisClient.get_client()` called `client.flushdb()` whenever `_client` was `None`. Celery autoscale (`--autoscale=6,1`) spawning new workers mid-stream triggered this path, nuking all Redis keys including active ownership keys, client records, and channel metadata. Removed the `flushdb()` call entirely. diff --git a/dispatcharr/tests/__init__.py b/dispatcharr/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dispatcharr/tests/test_wsgi.py b/dispatcharr/tests/test_wsgi.py new file mode 100644 index 00000000..cdf7a2e9 --- /dev/null +++ b/dispatcharr/tests/test_wsgi.py @@ -0,0 +1,39 @@ +"""Tests for dispatcharr/wsgi.py gevent monkey-patching.""" + +import os +import subprocess +import sys +import unittest + + +class TestWSGIGeventPatching(unittest.TestCase): + """Verify that wsgi.py applies gevent monkey-patching before Django imports. + + These tests run in subprocesses because monkey.patch_all() must execute + before any other imports. By the time Django's test runner loads this + file, ssl/socket are already imported, so calling patch_all() in-process + would fail. A subprocess mirrors how uWSGI actually loads wsgi.py. + """ + + def test_socket_is_patched_after_wsgi_import(self): + """Loading wsgi.py first (as uWSGI does) should patch sockets.""" + result = subprocess.run( + [ + sys.executable, "-c", + "import dispatcharr.wsgi; " + "from gevent import monkey; " + "assert monkey.is_module_patched('socket'), " + "'socket module was not patched by wsgi.py'; " + "print('PASS')", + ], + capture_output=True, + text=True, + env={**os.environ, "DJANGO_SETTINGS_MODULE": "dispatcharr.settings"}, + timeout=60, + cwd="/app", + ) + self.assertEqual( + result.returncode, + 0, + f"monkey-patching check failed:\n{result.stderr}", + ) diff --git a/dispatcharr/wsgi.py b/dispatcharr/wsgi.py index 29c2ba01..6e8860af 100644 --- a/dispatcharr/wsgi.py +++ b/dispatcharr/wsgi.py @@ -1,6 +1,21 @@ """ WSGI config for dispatcharr project. """ +# When running under uWSGI with gevent, ensure monkey-patching is fully +# applied before any other imports. Django's setup triggers the import of +# dispatcharr/__init__.py → celery.py, which initialises the Celery broker +# transport. If those imports happen before sockets are patched, Celery's +# Redis connection pool holds unpatched sockets that silently fail under +# gevent's cooperative scheduling. Calling patch_all() here—before the +# Django import chain—guarantees every socket (including pooled broker +# connections) is gevent-aware. The call is idempotent, so it's harmless +# if uWSGI's gevent plugin has already patched. +try: + from gevent import monkey + monkey.patch_all() +except ImportError: + pass + import os from django.core.wsgi import get_wsgi_application