diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index c1b1fbc..76c6741 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -637,15 +637,23 @@ class LaserDelay(PSIDeviceBase, PositionerBase): def on_stop(self) -> None: """Interrupt a running phase ramp between two steps and fail its status.""" + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been stopped")) + + def on_destroy(self) -> None: + """Interrupt a running move and fail its status before the base class kills the task. + + The base class shuts the task handler down by raising an exception in the task thread. That + can land anywhere in the task, including after the ramp but before the status is resolved, + so the status is failed here first; whoever waits on it (a scan, ``umv``) is released. + """ + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been destroyed")) + + def _interrupt_move(self, exc: Exception) -> None: with self._move_lock: self._move_stop.set() status = self._move_status if status is not None: - self._finish_status(status, DeviceStoppedError(f"Device {self.name} has been stopped")) - - def on_destroy(self) -> None: - """Interrupt a running move; the task handler is shut down by the base class.""" - self._move_stop.set() + self._finish_status(status, exc) # ----------------------------------------------------------------------------------------- # # Internals # diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index a27b730..49c2669 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -504,6 +504,31 @@ def test_destroy_ends_a_running_move(): assert not status.success +def test_destroy_fails_the_move_status_even_if_the_task_hangs(): + """destroy() (config reload, device server shutdown) must release whoever waits on the move, + also when the move task is stuck in a slow CA put and the thread kill does not reach it.""" + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_hardware(device) + device.calibrate(SCOPE) + blocker = threading.Event() + original_put = device.phase_v1._write_pv.put + + def hung_put(value, *args, **kwargs): + blocker.wait(timeout=5) + return original_put(value, *args, **kwargs) + + device.phase_v1._write_pv.put = hung_put + status = device.set(-4.9) + assert wait_until(device._hw_lock.locked) + with mock.patch.object(device.task_handler, "shutdown"): + device.destroy() + assert status.done and not status.success + with pytest.raises(DeviceStoppedError, match="destroyed"): + status.wait(timeout=0) + blocker.set() + assert wait_until(lambda: not device._hw_lock.locked()) + + def test_stored_vs_readback_reference_when_voltages_were_touched(calibrated): calibrated.set(1.0).wait(timeout=10) # somebody turns the phase shifter by hand