diff --git a/tests/docker/dirty_ttin_ttou/Dockerfile b/tests/docker/dirty_ttin_ttou/Dockerfile index 7d6c66ae..0c6cd837 100644 --- a/tests/docker/dirty_ttin_ttou/Dockerfile +++ b/tests/docker/dirty_ttin_ttou/Dockerfile @@ -6,9 +6,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -# Install gunicorn from source +# Install gunicorn from source. setproctitle is required so dirty-arbiter +# and dirty-worker processes get distinguishable names that the tests use +# to count workers via pgrep. COPY . /gunicorn-src/ -RUN pip install --no-cache-dir /gunicorn-src/ +RUN pip install --no-cache-dir /gunicorn-src/ setproctitle # Copy test app COPY tests/docker/dirty_ttin_ttou/app.py /app/ diff --git a/tests/docker/dirty_ttin_ttou/app.py b/tests/docker/dirty_ttin_ttou/app.py index 0030e5b8..df77da8d 100644 --- a/tests/docker/dirty_ttin_ttou/app.py +++ b/tests/docker/dirty_ttin_ttou/app.py @@ -45,10 +45,14 @@ def app(environ, start_response): start_response('200 OK', [('Content-Type', 'text/plain')]) return [b'OK'] + # client.execute(app_path, action, *args, **kwargs) — action is the + # method name on the DirtyApp. The original fixture passed the data + # dict where ``action`` belongs, which surfaced as a 500 from + # ``getattr(self, action)`` on the dirty worker. if path == '/unlimited': try: client = get_dirty_client() - result = client.execute('app:UnlimitedTask', {'test': 'data'}) + result = client.execute('app:UnlimitedTask', 'process', {'test': 'data'}) start_response('200 OK', [('Content-Type', 'application/json')]) return [json.dumps(result).encode()] except Exception as e: @@ -59,7 +63,7 @@ def app(environ, start_response): if path == '/limited': try: client = get_dirty_client() - result = client.execute('app:LimitedTask', {'test': 'data'}) + result = client.execute('app:LimitedTask', 'process', {'test': 'data'}) start_response('200 OK', [('Content-Type', 'application/json')]) return [json.dumps(result).encode()] except Exception as e: diff --git a/tests/docker/dirty_ttin_ttou/test_ttin_ttou_docker.py b/tests/docker/dirty_ttin_ttou/test_ttin_ttou_docker.py index ca72c26d..f63305c9 100644 --- a/tests/docker/dirty_ttin_ttou/test_ttin_ttou_docker.py +++ b/tests/docker/dirty_ttin_ttou/test_ttin_ttou_docker.py @@ -21,7 +21,10 @@ pytestmark = [ # Directory containing this test file TEST_DIR = Path(__file__).parent COMPOSE_FILE = TEST_DIR / "docker-compose.yml" -BASE_URL = "http://localhost:18000" +# Use 127.0.0.1 (not "localhost") so we always hit IPv4. Docker Desktop / +# OrbStack on macOS map host ports to IPv4 only, and ``localhost`` resolves +# to ``::1`` on this host, which yields connection-reset noise. +BASE_URL = "http://127.0.0.1:18000" @pytest.fixture(scope="module") @@ -100,6 +103,30 @@ def send_signal_to_dirty_arbiter(sig): ) +def wait_for_apps_ready(*paths, timeout=10): + """Poll the given app endpoints until each returns 200. + + The dirty arbiter rebalances apps across workers asynchronously after + TTIN/TTOU signals. Tests that care about app availability — rather + than worker counts — should call this between scaling and the request + so they don't race the rebalance. + """ + deadline = time.time() + timeout + pending = list(paths) + while pending and time.time() < deadline: + for path in list(pending): + try: + resp = requests.get(f"{BASE_URL}{path}", timeout=2) + if resp.status_code == 200: + pending.remove(path) + except requests.RequestException: + pass + if pending: + time.sleep(0.5) + if pending: + raise RuntimeError(f"Apps did not become ready: {pending}") + + class TestTTINSignal: """Test SIGTTIN increases dirty workers.""" @@ -159,14 +186,24 @@ class TestTTOUSignal: class TestUnlimitedApps: """Test apps with worker_count=None work correctly.""" - def test_unlimited_app_works(self, docker_services): + @pytest.fixture(autouse=True) + def _ready(self, docker_services): + # The TTOU-spam test before this class may leave the arbiter at + # the floor (2 workers). Bump the count back up so LimitedTask + # has spare capacity, then wait for both apps to be reachable. + for _ in range(2): + send_signal_to_dirty_arbiter("TTIN") + time.sleep(0.5) + wait_for_apps_ready("/unlimited", "/limited", timeout=30) + + def test_unlimited_app_works(self): """UnlimitedTask should work.""" resp = requests.get(f"{BASE_URL}/unlimited", timeout=10) assert resp.status_code == 200 data = resp.json() assert data["task"] == "unlimited" - def test_limited_app_works(self, docker_services): + def test_limited_app_works(self): """LimitedTask should work.""" resp = requests.get(f"{BASE_URL}/limited", timeout=10) assert resp.status_code == 200 diff --git a/tests/docker/per_app_allocation/README.md b/tests/docker/per_app_allocation/README.md index bae552d7..91535227 100644 --- a/tests/docker/per_app_allocation/README.md +++ b/tests/docker/per_app_allocation/README.md @@ -58,5 +58,5 @@ pytest test_per_app_e2e.py::TestPerAppAllocation::test_config_limited_app_uses_o ## Notes -- Tests run on port 8001 to avoid conflicts with the existing dirty_arbiter tests on 8000 +- Tests run on port 28001 to avoid conflicts with the existing dirty_arbiter tests on 8000 and with macOS Docker alternatives that often reserve port 8001 (e.g., OrbStack's vcom-tunnel) - The container uses a keep-alive wrapper to allow testing worker crash scenarios diff --git a/tests/docker/per_app_allocation/docker-compose.yml b/tests/docker/per_app_allocation/docker-compose.yml index 19aaf7c8..a389cd0b 100644 --- a/tests/docker/per_app_allocation/docker-compose.yml +++ b/tests/docker/per_app_allocation/docker-compose.yml @@ -4,7 +4,7 @@ services: context: ../../.. dockerfile: tests/docker/per_app_allocation/Dockerfile ports: - - "8001:8000" + - "28001:8000" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/status"] interval: 1s diff --git a/tests/docker/per_app_allocation/test_per_app_e2e.py b/tests/docker/per_app_allocation/test_per_app_e2e.py index 1abcb1b3..e1c34472 100644 --- a/tests/docker/per_app_allocation/test_per_app_e2e.py +++ b/tests/docker/per_app_allocation/test_per_app_e2e.py @@ -39,7 +39,7 @@ class DockerContainer: self.name = name self.build = build self.container_id = None - self.base_url = "http://127.0.0.1:8001" + self.base_url = "http://127.0.0.1:28001" def __enter__(self): # Build if requested @@ -64,7 +64,7 @@ class DockerContainer: [ "docker", "run", "-d", "--name", self.name, - "-p", "8001:8000", + "-p", "28001:8000", "per_app_allocation-gunicorn", "sh", "-c", "gunicorn app:application -c gunicorn_conf.py & "