@@ -466,6 +466,87 @@ def test_hub_reset_on_connection_error(self):
466466 asynloop (* x .args )
467467 x .hub .reset .assert_called_once ()
468468
469+ def test_hub_timer_cleared_on_connection_error (self ):
470+ # Stale timer entries (e.g. maybe_restore_messages) must be cleared
471+ # when the event loop exits due to a connection error. Without this,
472+ # entries accumulated across reconnects can fire against the broken
473+ # connection and crash the loop again before the new connection is
474+ # fully established, causing a rapid restart loop.
475+ x = X (self .app )
476+ x .hub .readers = {6 : Mock ()}
477+ x .hub .timer ._queue = [1 ]
478+ x .hub .reset = Mock (name = 'hub.reset()' )
479+ x .close_then_error (x .hub .poller .poll )
480+ x .hub .fire_timers .return_value = 33.37
481+ x .hub .poller .poll .return_value = []
482+ with pytest .raises (socket .error ):
483+ asynloop (* x .args )
484+ x .hub .timer .clear .assert_called_once ()
485+
486+ def test_hub_timer_not_cleared_on_graceful_shutdown (self ):
487+ # On graceful shutdown the timer queue must be left intact so that
488+ # periodic timers (e.g. heartbeat) keep firing while the pool drains.
489+ x = X (self .app )
490+ x .hub .reset = Mock (name = 'hub.reset()' )
491+ x .hub .on_tick .add (x .closer (mod = 2 ))
492+ asynloop (* x .args )
493+ x .hub .timer .clear .assert_not_called ()
494+
495+ def test_hub_timer_not_cleared_on_worker_shutdown (self ):
496+ x = X (self .app )
497+ x .hub .reset = Mock (name = 'hub.reset()' )
498+ state .should_stop = 303
499+ try :
500+ with pytest .raises (WorkerShutdown ):
501+ asynloop (* x .args )
502+ finally :
503+ state .should_stop = None
504+ x .hub .timer .clear .assert_not_called ()
505+
506+ def test_hub_timer_not_cleared_on_worker_terminate (self ):
507+ x = X (self .app )
508+ x .hub .reset = Mock (name = 'hub.reset()' )
509+ state .should_terminate = True
510+ try :
511+ with pytest .raises (WorkerTerminate ):
512+ asynloop (* x .args )
513+ finally :
514+ state .should_terminate = None
515+ x .hub .timer .clear .assert_not_called ()
516+
517+ def test_hub_timer_clear_error_still_reraises_original (self ):
518+ # If hub.timer.clear() itself raises, the original connection error
519+ # must still be propagated, not the cleanup error.
520+ x = X (self .app )
521+ x .hub .readers = {6 : Mock ()}
522+ x .hub .timer ._queue = [1 ]
523+ x .hub .reset = Mock (name = 'hub.reset()' )
524+ x .hub .timer .clear = Mock (
525+ name = 'hub.timer.clear()' , side_effect = RuntimeError ('clear failed' )
526+ )
527+ x .close_then_error (x .hub .poller .poll )
528+ x .hub .fire_timers .return_value = 33.37
529+ x .hub .poller .poll .return_value = []
530+ with pytest .raises (socket .error ):
531+ asynloop (* x .args )
532+ x .hub .timer .clear .assert_called_once ()
533+
534+ def test_hub_timer_cleared_even_when_reset_raises (self ):
535+ # hub.timer.clear() must still be called even if hub.reset() raises.
536+ # The two cleanup calls are in separate try/except blocks so that a
537+ # failure in hub.reset() does not prevent stale timer entries from
538+ # being discarded, avoiding stale timers persisting after a reset error.
539+ x = X (self .app )
540+ x .hub .readers = {6 : Mock ()}
541+ x .hub .timer ._queue = [1 ]
542+ x .hub .reset = Mock (name = 'hub.reset()' , side_effect = RuntimeError ('reset failed' ))
543+ x .close_then_error (x .hub .poller .poll )
544+ x .hub .fire_timers .return_value = 33.37
545+ x .hub .poller .poll .return_value = []
546+ with pytest .raises (socket .error ):
547+ asynloop (* x .args )
548+ x .hub .timer .clear .assert_called_once ()
549+
469550 def test_hub_not_reset_on_graceful_shutdown (self ):
470551 x = X (self .app )
471552 x .hub .reset = Mock (name = 'hub.reset()' )
0 commit comments