From 05644456e0027c501a45230e3036b89acc34d772 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 12 Aug 2025 15:35:39 +0100 Subject: [PATCH 1/2] Allow preview to cancel! --- .../stitching.py | 25 ++++++++++++------- .../things/smart_scan.py | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/openflexure_microscope_server/stitching.py b/src/openflexure_microscope_server/stitching.py index 9ef7561f..55b8bc0a 100644 --- a/src/openflexure_microscope_server/stitching.py +++ b/src/openflexure_microscope_server/stitching.py @@ -9,6 +9,7 @@ Interpreter Lock (GIL). May be possible to shift to multiprocessing in the futur from typing import Optional, Any import threading import subprocess +import signal import os from io import TextIOWrapper import shlex @@ -160,11 +161,19 @@ class PreviewStitcher(BaseStitcher): return True return False - def wait(self) -> None: - """Wait for this preview stitch to return.""" - if self.running: - with self._popen_lock: - self._popen_obj.wait() + def wait(self, cancel: lt.deps.CancelHook) -> None: + """Wait for this preview stitch to return. + + :raises InvocationCancelledError: if the action is cancelled. + """ + while self.running: + try: + cancel.sleep(0.1) + except lt.exceptions.InvocationCancelledError as e: + with self._popen_lock: + if self._popen_obj is not None: + self._popen_obj.send_signal(signal.SIGKILL) + raise (e) class FinalStitcher(BaseStitcher): @@ -272,10 +281,8 @@ class FinalStitcher(BaseStitcher): ) -> None: """Run the final stitch logging any output. - Raises: - ChildProcessError if exit code is not zero - InvocationCancelledError if the action is cancelled. - + :raises ChildProcessError: if exit code is not zero + :raises InvocationCancelledError: if the action is cancelled. """ cmd = self.command self.logger.debug(f"Running command in subprocess: `{' '.join(cmd)}`") diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index c4206bcc..c769d1fd 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -466,7 +466,7 @@ class SmartScanThing(lt.Thing): self._scan_logger.info("Waiting for background processes to finish...") if self._preview_stitcher is not None: - self._preview_stitcher.wait() + self._preview_stitcher.wait(self._cancel) if self._scan_data.stitch_automatically: self._scan_logger.info("Stitching final image (may take some time)...") From c066843f9b75283a14ac6de818cab0bb72d984b7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 12 Aug 2025 16:42:15 +0100 Subject: [PATCH 2/2] Test cancelling preview --- tests/mock_stitching/mock-stitch.py | 3 +++ tests/test_stitching.py | 35 ++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/mock_stitching/mock-stitch.py b/tests/mock_stitching/mock-stitch.py index b4c9d16a..341ed61c 100644 --- a/tests/mock_stitching/mock-stitch.py +++ b/tests/mock_stitching/mock-stitch.py @@ -11,6 +11,9 @@ def main(): # This is used to check we catch errors correctly. if arg == "ERROR": raise RuntimeError("I was told to do this.") + if arg == "HANG": + # Rather than hang, just sleep for 10s: + time.sleep(10) print(arg, flush=True) time.sleep(0.2) diff --git a/tests/test_stitching.py b/tests/test_stitching.py index d4908340..95ebd355 100644 --- a/tests/test_stitching.py +++ b/tests/test_stitching.py @@ -9,7 +9,7 @@ import os from copy import copy import uuid import threading -from time import sleep +import time import pytest @@ -200,6 +200,7 @@ def test_preview_stitching_command(caplog, mocker): mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd) with caplog.at_level(logging.INFO): + cancel_hook = lt.deps.CancelHook(id=uuid.uuid4()) stitcher = PreviewStitcher(FAKE_DIR, overlap=0.1, correlation_resize=0.5) stitcher.start() # Should take a second or so to run so will still be running @@ -208,12 +209,40 @@ def test_preview_stitching_command(caplog, mocker): with pytest.raises(RuntimeError): stitcher.start() # Wait for it to complete - stitcher.wait() + stitcher.wait(cancel_hook) # It is now not running assert not stitcher.running assert len(caplog.records) == 0 +def test_preview_stitching_cancelled(caplog, mocker): + """Check that preview stitch can be cancelled.""" + mock_cmd = f"python {MOCK_STITCHER}" + + mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd) + + with caplog.at_level(logging.INFO): + stitcher = PreviewStitcher(FAKE_DIR, overlap=0.1, correlation_resize=0.5) + # Send in the argument HANG to mock-stitch and it just hang for 10s + stitcher._extra_args = ["HANG"] + cancel_hook = lt.deps.CancelHook(id=uuid.uuid4()) + t_start = time.time() + # Start stitching + stitcher.start() + # Run wait() in a thread. + thread = threading.Thread(target=stitcher.wait, kwargs={"cancel": cancel_hook}) + thread.start() + # Sleep long enough for at least 1 log. + time.sleep(0.5) + # use set() to cancel! + cancel_hook.set() + thread.join() + # If it wasn't cancelled it would hang for 10 s. Here we check the cancel killed + # it within 2s. + assert t_start - time.time() < 2 + assert len(caplog.records) == 0 + + def test_final_stitching_command(caplog, mocker): """Check the final stitch runs until completion, and print statements are logged.""" mock_cmd = f"python {MOCK_STITCHER}" @@ -252,7 +281,7 @@ def test_final_stitching_command_cancelled(caplog, mocker): thread = threading.Thread(target=stitcher.run, kwargs={"cancel": cancel_hook}) thread.start() # Sleep long enough for at least 1 log. - sleep(0.5) + time.sleep(0.5) # use set() to cancel! cancel_hook.set() thread.join()