@@ -151,6 +151,33 @@ async def start() -> None:
151151 await stop ()
152152
153153
154+ @contextlib .asynccontextmanager
155+ async def _unresponsive_ws_server (monkeypatch : pytest .MonkeyPatch ) -> AsyncGenerator [None ]:
156+ """A `127.0.0.1` server that accepts connections but never completes the WebSocket handshake.
157+
158+ It keeps `__aenter__` waiting for its first connection, which is what lets a test cancel it mid-connect.
159+ """
160+ shutdown = asyncio .Event ()
161+
162+ async def handler (_reader : asyncio .StreamReader , writer : asyncio .StreamWriter ) -> None :
163+ try :
164+ await shutdown .wait ()
165+ finally :
166+ writer .close ()
167+
168+ server = await asyncio .start_server (handler , host = '127.0.0.1' )
169+ port : int = server .sockets [0 ].getsockname ()[1 ]
170+ monkeypatch .setenv (ActorEnvVars .EVENTS_WEBSOCKET_URL , f'ws://127.0.0.1:{ port } ' )
171+
172+ try :
173+ yield
174+ finally :
175+ # Release the handlers first, `wait_closed` would block on them otherwise.
176+ shutdown .set ()
177+ server .close ()
178+ await server .wait_closed ()
179+
180+
154181async def test_lifecycle_local (caplog : pytest .LogCaptureFixture ) -> None :
155182 caplog .set_level (logging .DEBUG , logger = 'apify' )
156183
@@ -260,10 +287,12 @@ async def test_lifecycle_on_platform_without_websocket(monkeypatch: pytest.Monke
260287 monkeypatch .setenv (ActorEnvVars .EVENTS_WEBSOCKET_URL , 'ws://localhost:56565' )
261288 event_manager = ApifyEventManager (Configuration .get_global_configuration ())
262289
263- with pytest .raises (RuntimeError , match = r'Error connecting to platform events websocket!' ):
290+ with pytest .raises (RuntimeError , match = r'Error connecting to platform events websocket!' ) as exc_info :
264291 async with event_manager :
265292 pass
266293
294+ # The error that prevented the connection is reported as the cause, not only logged.
295+ assert isinstance (exc_info .value .__cause__ , OSError )
267296 assert event_manager .active is False
268297 persist_state_task = event_manager ._emit_persist_state_event_rec_task .task
269298 assert persist_state_task is None or persist_state_task .done ()
@@ -278,6 +307,119 @@ async def test_lifecycle_on_platform(monkeypatch: pytest.MonkeyPatch) -> None:
278307 assert len (connected_ws_clients ) == 1
279308
280309
310+ async def test_nested_context_keeps_a_single_websocket (monkeypatch : pytest .MonkeyPatch ) -> None :
311+ """A nested context reuses the single platform connection, and only the outermost exit tears it down."""
312+ async with _platform_ws_server (monkeypatch ) as (connected_ws_clients , client_connected ):
313+ event_manager = ApifyEventManager (Configuration .get_global_configuration ())
314+
315+ async with event_manager :
316+ await client_connected .wait ()
317+ assert len (connected_ws_clients ) == 1
318+ task = event_manager ._process_platform_messages_task
319+
320+ # A crawler running under an Actor enters the already-entered event manager again.
321+ async with event_manager :
322+ await asyncio .sleep (0.2 )
323+ assert len (connected_ws_clients ) == 1
324+ assert event_manager ._process_platform_messages_task is task
325+
326+ # The inner exit must leave the connection alone, the Actor still needs the platform events.
327+ await asyncio .sleep (0.2 )
328+ assert len (connected_ws_clients ) == 1
329+ assert task is not None
330+ assert not task .done ()
331+
332+ # A single connection also means every event is delivered exactly once.
333+ event_calls : list [Any ] = []
334+ event_manager .on (event = Event .SYSTEM_INFO , listener = event_calls .append )
335+ websockets .broadcast (connected_ws_clients , json .dumps ({'name' : 'systemInfo' , 'data' : DUMMY_SYSTEM_INFO }))
336+ await poll_until_condition (lambda : bool (event_calls ), poll_interval = 0.05 )
337+ await asyncio .sleep (0.2 )
338+ assert len (event_calls ) == 1
339+
340+ # Poll because the server-side handler may not have deregistered its connection yet.
341+ await poll_until_condition (lambda : not connected_ws_clients , poll_interval = 0.05 )
342+ assert not connected_ws_clients
343+ assert task .done ()
344+
345+
346+ async def test_context_can_be_reentered_after_full_exit (monkeypatch : pytest .MonkeyPatch ) -> None :
347+ """Entering a fully exited event manager again opens a fresh platform connection."""
348+ async with _platform_ws_server (monkeypatch ) as (connected_ws_clients , client_connected ):
349+ event_manager = ApifyEventManager (Configuration .get_global_configuration ())
350+
351+ async with event_manager :
352+ await client_connected .wait ()
353+ assert len (connected_ws_clients ) == 1
354+
355+ await poll_until_condition (lambda : not connected_ws_clients , poll_interval = 0.05 )
356+ assert event_manager ._process_platform_messages_task is None
357+ assert event_manager ._platform_events_websocket is None
358+
359+ client_connected .clear ()
360+ async with event_manager :
361+ await asyncio .wait_for (client_connected .wait (), timeout = 10 )
362+ assert len (connected_ws_clients ) == 1
363+
364+
365+ async def test_cancelled_entry_leaves_no_stale_state (monkeypatch : pytest .MonkeyPatch ) -> None :
366+ """A cancelled entry releases the context, so the next entry connects again instead of looking like a nested one."""
367+ async with _unresponsive_ws_server (monkeypatch ):
368+ event_manager = ApifyEventManager (Configuration .get_global_configuration ())
369+
370+ first_entry = asyncio .create_task (event_manager .__aenter__ ())
371+ await asyncio .sleep (0.2 )
372+ assert not first_entry .done ()
373+
374+ first_entry .cancel ()
375+ with contextlib .suppress (asyncio .CancelledError ):
376+ await first_entry
377+
378+ assert event_manager .active is False
379+ assert event_manager ._process_platform_messages_task is None
380+ assert event_manager ._platform_events_websocket is None
381+ persist_state_task = event_manager ._emit_persist_state_event_rec_task .task
382+ assert persist_state_task is None or persist_state_task .done ()
383+
384+ # The next entry has to attempt a connection of its own, rather than return a manager receiving no events.
385+ second_entry = asyncio .create_task (event_manager .__aenter__ ())
386+ await asyncio .sleep (0.2 )
387+ assert not second_entry .done ()
388+ assert event_manager ._active_ref_count == 1
389+ assert event_manager ._process_platform_messages_task is not None
390+
391+ second_entry .cancel ()
392+ with contextlib .suppress (asyncio .CancelledError ):
393+ await second_entry
394+ assert event_manager .active is False
395+
396+
397+ async def test_exit_releases_context_when_the_websocket_shutdown_fails (monkeypatch : pytest .MonkeyPatch ) -> None :
398+ """A failing websocket shutdown still releases the context, so the manager cannot stay active for good."""
399+ async with _platform_ws_server (monkeypatch ) as (_ , client_connected ):
400+ event_manager = ApifyEventManager (Configuration .get_global_configuration ())
401+ await event_manager .__aenter__ ()
402+ await client_connected .wait ()
403+
404+ monkeypatch .setattr (
405+ event_manager , '_teardown_platform_websocket' , Mock (side_effect = RuntimeError ('close failed' ))
406+ )
407+
408+ with pytest .raises (RuntimeError , match = 'close failed' ):
409+ await event_manager .__aexit__ (None , None , None )
410+
411+ assert event_manager .active is False
412+ persist_state_task = event_manager ._emit_persist_state_event_rec_task .task
413+ assert persist_state_task is None or persist_state_task .done ()
414+
415+ # The mocked shutdown left the message-processing task running.
416+ task = event_manager ._process_platform_messages_task
417+ assert task is not None
418+ task .cancel ()
419+ with contextlib .suppress (asyncio .CancelledError ):
420+ await task
421+
422+
281423async def test_event_handling_on_platform (monkeypatch : pytest .MonkeyPatch ) -> None :
282424 async with _platform_ws_server (monkeypatch ) as (connected_ws_clients , client_connected ):
283425
0 commit comments