diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index c52f0df..3be14ff 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -657,6 +657,7 @@ def status_server(): thread.start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown() + server.server_close() class TestServiceAlive: @@ -683,6 +684,33 @@ class TestExternalServer: def test_raises_when_dead(self): with pytest.raises(ConnectionError, match="no WebDriver server"): ExternalServer("http://127.0.0.1:1").ensure_running() + + +class TestServiceAliveNonHttpResponder: + def test_non_http_service_on_port_is_false_and_clean_error(self): + # wrong-port misconfiguration: some non-HTTP service answers the socket + import socket + + server = socket.create_server(("127.0.0.1", 0)) + port = server.getsockname()[1] + + def answer(count=2): + for _ in range(count): + try: + conn, _ = server.accept() + except OSError: + return + conn.sendall(b"SSH-2.0-OpenSSH_9.7\r\n") + conn.close() + + thread = threading.Thread(target=answer, daemon=True) + thread.start() + try: + assert service_alive(f"http://127.0.0.1:{port}", timeout=2.0) is False + with pytest.raises(ConnectionError, match="no WebDriver server"): + ExternalServer(f"http://127.0.0.1:{port}").ensure_running() + finally: + server.close() ``` - [ ] **Step 2: Run tests to verify they fail** @@ -705,6 +733,7 @@ Selenium Grid or standalone driver the user runs) and ``ManagedService`` from __future__ import annotations +import http.client import json import logging import urllib.request @@ -764,12 +793,13 @@ def service_alive(url: str, timeout: float = 2.0) -> bool: Liveness only: geckodriver reports ready=false while its single session is in use, so the ``ready`` flag is deliberately ignored. + The ``timeout`` bounds each socket operation, not total wall-clock time. """ try: with urllib.request.urlopen(f"{url.rstrip('/')}/status", timeout=timeout) as resp: json.loads(resp.read()) return resp.status == 200 - except (OSError, ValueError): + except (OSError, ValueError, http.client.HTTPException): return False @@ -788,7 +818,7 @@ class ExternalServer: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_hosts.py -v` -Expected: 12 passed +Expected: 13 passed - [ ] **Step 5: Commit** @@ -898,6 +928,7 @@ Expected: new tests FAIL — `ImportError: cannot import name 'ManagedService'`; Append to `src/wabot/hosts.py`, and extend the top import block to: ```python +import http.client import json import logging import os @@ -1006,7 +1037,7 @@ def stop_service(pid: int) -> bool: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_hosts.py -v` -Expected: 17 passed (12 from Task 4 + 5 new) +Expected: 18 passed (13 from Task 4 + 5 new) - [ ] **Step 5: Commit** @@ -1142,7 +1173,7 @@ Run: `uv run pytest tests/unit/test_reattach.py -v` Expected: 5 passed Run: `uv run pytest` -Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 17, reattach 5 = 43) +Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 18, reattach 5 = 44) - [ ] **Step 5: Commit** @@ -2667,7 +2698,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (104 tests: pacing 7, sessions 14, hosts 17, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (105 tests: pacing 7, sessions 14, hosts 18, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/hosts.py b/src/wabot/hosts.py index 8b37da7..311e4fb 100644 --- a/src/wabot/hosts.py +++ b/src/wabot/hosts.py @@ -8,6 +8,7 @@ Selenium Grid or standalone driver the user runs) and ``ManagedService`` from __future__ import annotations +import http.client import json import logging import urllib.request @@ -67,12 +68,13 @@ def service_alive(url: str, timeout: float = 2.0) -> bool: Liveness only: geckodriver reports ready=false while its single session is in use, so the ``ready`` flag is deliberately ignored. + The ``timeout`` bounds each socket operation, not total wall-clock time. """ try: with urllib.request.urlopen(f"{url.rstrip('/')}/status", timeout=timeout) as resp: json.loads(resp.read()) return resp.status == 200 - except (OSError, ValueError): + except (OSError, ValueError, http.client.HTTPException): return False diff --git a/tests/unit/test_hosts.py b/tests/unit/test_hosts.py index 68025a5..329e7aa 100644 --- a/tests/unit/test_hosts.py +++ b/tests/unit/test_hosts.py @@ -65,6 +65,7 @@ def status_server(): thread.start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown() + server.server_close() class TestServiceAlive: @@ -91,3 +92,30 @@ class TestExternalServer: def test_raises_when_dead(self): with pytest.raises(ConnectionError, match="no WebDriver server"): ExternalServer("http://127.0.0.1:1").ensure_running() + + +class TestServiceAliveNonHttpResponder: + def test_non_http_service_on_port_is_false_and_clean_error(self): + # wrong-port misconfiguration: some non-HTTP service answers the socket + import socket + + server = socket.create_server(("127.0.0.1", 0)) + port = server.getsockname()[1] + + def answer(count=2): + for _ in range(count): + try: + conn, _ = server.accept() + except OSError: + return + conn.sendall(b"SSH-2.0-OpenSSH_9.7\r\n") + conn.close() + + thread = threading.Thread(target=answer, daemon=True) + thread.start() + try: + assert service_alive(f"http://127.0.0.1:{port}", timeout=2.0) is False + with pytest.raises(ConnectionError, match="no WebDriver server"): + ExternalServer(f"http://127.0.0.1:{port}").ensure_running() + finally: + server.close()