@@ -184,6 +184,25 @@ def test__heartbeat_expected(self, mock_time):
184184 mock_time .return_value = 110
185185 self .assertTrue (self .heartbeater ._heartbeat_expected ())
186186
187+ def test_stop_not_started (self ):
188+ """Test stop() when thread was never started."""
189+ # Thread is not alive, should not call join()
190+ self .assertFalse (self .heartbeater .is_alive ())
191+ result = self .heartbeater .stop ()
192+ self .assertIsNone (result )
193+ self .assertTrue (self .heartbeater .stop_event .set .called )
194+
195+ @mock .patch .object (agent .IronicPythonAgentHeartbeater , 'join' ,
196+ autospec = True )
197+ def test_stop_when_alive (self , mock_join ):
198+ """Test stop() when thread is alive."""
199+ # Mock the thread as alive
200+ with mock .patch .object (self .heartbeater , 'is_alive' ,
201+ autospec = True , return_value = True ):
202+ self .heartbeater .stop ()
203+ mock_join .assert_called_once_with (self .heartbeater )
204+ self .assertTrue (self .heartbeater .stop_event .set .called )
205+
187206
188207@mock .patch .object (hardware , '_md_scan_and_assemble' , lambda : None )
189208@mock .patch .object (hardware , '_check_for_iscsi' , lambda : None )
@@ -800,6 +819,49 @@ def set_serve_api(*args, **kwargs):
800819 mock .call ('wait_for_disks' )],
801820 mock_dispatch .call_args_list )
802821
822+ @mock .patch (
823+ 'ironic_python_agent.hardware_managers.cna._detect_cna_card' ,
824+ mock .Mock ())
825+ @mock .patch ('os.path.exists' , autospec = True )
826+ @mock .patch .object (hardware , 'get_managers' , autospec = True )
827+ @mock .patch .object (time , 'sleep' , autospec = True )
828+ @mock .patch .object (agent .IronicPythonAgent , '_wait_for_interface' ,
829+ autospec = True )
830+ @mock .patch .object (hardware , 'dispatch_to_managers' , autospec = True )
831+ def test_run_rescue_mode_heartbeater_not_started (
832+ self , mock_dispatch , mock_wait , mock_sleep , mock_get_managers ,
833+ mock_exists ):
834+ """Test rescue mode doesn't fail when heartbeater not started."""
835+ CONF .set_override ('inspection_callback_url' , '' )
836+
837+ # Mock rescue mode marker file exists
838+ mock_exists .return_value = True
839+
840+ self .agent .heartbeater = mock .Mock ()
841+ self .agent .heartbeater .is_alive .return_value = False
842+ self .agent .api_client .lookup_node = mock .Mock ()
843+ self .agent .api_client .lookup_node .return_value = {
844+ 'node' : {
845+ 'uuid' : 'deadbeef-dabb-ad00-b105-f00d00bab10c'
846+ },
847+ 'config' : {
848+ 'heartbeat_timeout' : 300
849+ }
850+ }
851+
852+ # Setup to exit the infinite loop after first iteration
853+ mock_sleep .side_effect = [None , KeyboardInterrupt ()]
854+
855+ try :
856+ self .agent .run ()
857+ except KeyboardInterrupt :
858+ pass
859+
860+ # Heartbeater should not be started or stopped in rescue mode
861+ self .agent .heartbeater .start .assert_not_called ()
862+ self .agent .heartbeater .stop .assert_not_called ()
863+ self .assertFalse (self .agent .serve_api )
864+
803865 def test_async_command_success (self ):
804866 result = base .AsyncCommandResult ('foo_command' , {'fail' : False },
805867 foo_execute )
0 commit comments