@@ -390,3 +390,112 @@ async def test_connect_on_invoke_false_no_reconnect(self, server_url: str):
390390 finally :
391391 # transport already closed above
392392 pass
393+
394+
395+ # =====================================================================
396+ # Regression: stale connect-task must not block fail-fast
397+ # =====================================================================
398+
399+
400+ class TestStaleConnectTask :
401+ @pytest .mark .asyncio
402+ async def test_done_connect_task_does_not_block_failfast (self ):
403+ """A completed (done) connect task in _connect_tasks must not
404+ prevent the fail-fast path from firing.
405+
406+ Regression: previously the check was `to not in _connect_tasks`,
407+ so a done task kept the entry alive and calls would hang instead
408+ of failing immediately when retries were exhausted.
409+ """
410+ transport = WebSocketClientTransport (
411+ ws_url = "ws://127.0.0.1:1" , # unreachable
412+ client_id = None ,
413+ server_id = "STALE" ,
414+ codec = NaiveJsonCodec (),
415+ options = SessionOptions (
416+ connection_timeout_ms = 100 ,
417+ handshake_timeout_ms = 100 ,
418+ session_disconnect_grace_ms = 200 ,
419+ ),
420+ )
421+ transport .reconnect_on_connection_drop = False
422+ try :
423+ # Trigger a connect that will fail
424+ transport .connect ("STALE" )
425+ await wait_for_session_gone (transport , "STALE" )
426+
427+ # The done task is still in _connect_tasks
428+ assert "STALE" in transport ._connect_tasks
429+ assert transport ._connect_tasks ["STALE" ].done ()
430+
431+ # Exhaust the retry budget so connect() is a no-op
432+ transport ._retry_budget .budget_consumed = (
433+ transport ._retry_budget .attempt_budget_capacity
434+ )
435+
436+ # RPC must fail immediately, not hang
437+ client = RiverClient (
438+ transport , server_id = "STALE" , connect_on_invoke = True
439+ )
440+ result = await asyncio .wait_for (
441+ client .rpc ("test" , "add" , {"n" : 1 }), timeout = 1.0
442+ )
443+ assert result ["ok" ] is False
444+ assert result ["payload" ]["code" ] == "UNEXPECTED_DISCONNECT"
445+ finally :
446+ await transport .close ()
447+
448+
449+ # =====================================================================
450+ # Regression: grace period must not reset on each failed reconnect
451+ # =====================================================================
452+
453+
454+ class TestGracePeriodNotResetOnRetry :
455+ @pytest .mark .asyncio
456+ async def test_grace_period_not_extended_by_retries (self , server_url : str ):
457+ """Repeated connection failures must not restart the grace timer.
458+
459+ Regression: _on_connection_failed() unconditionally called
460+ start_grace_period(), which cancelled and restarted the timer
461+ on every retry, extending session lifetime far beyond
462+ session_disconnect_grace_ms.
463+ """
464+ grace_ms = 400
465+ transport = WebSocketClientTransport (
466+ ws_url = "ws://127.0.0.1:1" , # unreachable
467+ client_id = None ,
468+ server_id = "GRACE" ,
469+ codec = NaiveJsonCodec (),
470+ options = SessionOptions (
471+ connection_timeout_ms = 100 ,
472+ handshake_timeout_ms = 100 ,
473+ session_disconnect_grace_ms = grace_ms ,
474+ ),
475+ )
476+ try :
477+ transport .connect ("GRACE" )
478+
479+ # Wait for at least one connection failure to set the grace period
480+ await wait_for (
481+ lambda : (
482+ (s := transport .sessions .get ("GRACE" )) is not None
483+ and s ._grace_period_task is not None
484+ ),
485+ timeout = 2.0 ,
486+ )
487+
488+ session = transport .sessions ["GRACE" ]
489+ original_expiry = session ._grace_expiry_time
490+ assert original_expiry is not None
491+
492+ # After further retries, the expiry time must not have moved forward
493+ await asyncio .sleep (0.2 )
494+ session2 = transport .sessions .get ("GRACE" )
495+ if session2 is not None and session2 ._grace_expiry_time is not None :
496+ assert session2 ._grace_expiry_time <= original_expiry
497+
498+ # Session should be gone within grace_ms + generous margin
499+ await wait_for_session_gone (transport , "GRACE" , timeout = 3.0 )
500+ finally :
501+ await transport .close ()
0 commit comments