Skip to content

Commit 8a19e53

Browse files
authored
test: Fix flaky test_lifecycle_on_platform_without_websocket (#1121)
`test_lifecycle_on_platform_without_websocket` pointed the events WebSocket URL at a hard-coded `ws://localhost:56565` and asserted that the connection is refused. That port sits inside the OS ephemeral range (32768-60999 on Linux, 49152-65535 on Windows), so any `bind(host, 0)` elsewhere in the parallel suite can be handed exactly that port, and this test file alone starts three such servers. When a sibling xdist worker got 56565, the connection succeeded and the test failed with `DID NOT RAISE RuntimeError`, as in [this Windows run](https://github.com/apify/apify-sdk-python/actions/runs/34123143315/job/101745572277). Attempt 2 of the same run passed on the same commit. The test now reserves a port instead of guessing one. A new `_unreachable_ws_url` helper binds `('127.0.0.1', 0)` and holds the socket open without calling `listen()`. Holding it keeps the OS from handing the port to anyone else, and a bound socket that never listens refuses every connect with `ECONNREFUSED`, which is the `OSError` the test asserts as the cause. The assertions themselves are unchanged. Running a real WebSocket server on `127.0.0.1:56565` reproduces the CI failure exactly, which gives a before/after: 20/20 runs failed before the fix, 0/100 after. The fixed test also survived 0/25 whole-file runs under `--numprocesses=auto` with that port occupied, and 0/60 runs with a background process holding ~400 rotating ephemeral listeners. *✍️ Drafted by Claude Code*
1 parent 8532389 commit 8a19e53

1 file changed

Lines changed: 22 additions & 6 deletions

File tree

tests/unit/events/test_apify_event_manager.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from apify.events._types import SystemInfoEventData
2626

2727
if TYPE_CHECKING:
28-
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
28+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterator
2929

3030

3131
DUMMY_SYSTEM_INFO = {
@@ -178,6 +178,22 @@ async def handler(_reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -
178178
await server.wait_closed()
179179

180180

181+
@contextlib.contextmanager
182+
def _unreachable_ws_url(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
183+
"""Point the events WebSocket URL at a `127.0.0.1` port that is guaranteed to refuse connections.
184+
185+
A hard-coded port number cannot be assumed dead: every port in the OS ephemeral range is fair game for any
186+
`bind(host, 0)` in the suite, so a parallel xdist worker's server can end up listening on exactly that port and
187+
the connection then succeeds instead of being refused. Reserving a port with a socket that never calls `listen()`
188+
keeps the OS from handing it out to anyone else, while leaving every connect attempt refused with `ECONNREFUSED`.
189+
"""
190+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reserved_sock:
191+
reserved_sock.bind(('127.0.0.1', 0))
192+
port: int = reserved_sock.getsockname()[1]
193+
monkeypatch.setenv(ActorEnvVars.EVENTS_WEBSOCKET_URL, f'ws://127.0.0.1:{port}')
194+
yield
195+
196+
181197
async def test_lifecycle_local(caplog: pytest.LogCaptureFixture) -> None:
182198
caplog.set_level(logging.DEBUG, logger='apify')
183199

@@ -284,12 +300,12 @@ async def event_handler(data: Any) -> None:
284300

285301
async def test_lifecycle_on_platform_without_websocket(monkeypatch: pytest.MonkeyPatch) -> None:
286302
"""Test that a failed websocket connection raises and also exits the parent's recurring persist state task."""
287-
monkeypatch.setenv(ActorEnvVars.EVENTS_WEBSOCKET_URL, 'ws://localhost:56565')
288-
event_manager = ApifyEventManager(Configuration.get_global_configuration())
303+
with _unreachable_ws_url(monkeypatch):
304+
event_manager = ApifyEventManager(Configuration.get_global_configuration())
289305

290-
with pytest.raises(RuntimeError, match=r'Error connecting to platform events websocket!') as exc_info:
291-
async with event_manager:
292-
pass
306+
with pytest.raises(RuntimeError, match=r'Error connecting to platform events websocket!') as exc_info:
307+
async with event_manager:
308+
pass
293309

294310
# The error that prevented the connection is reported as the cause, not only logged.
295311
assert isinstance(exc_info.value.__cause__, OSError)

0 commit comments

Comments
 (0)