Merge branch 'let-preview-stitch-cancel' into 'v3'

Allow preview to cancel!

See merge request openflexure/openflexure-microscope-server!359
This commit is contained in:
Julian Stirling 2025-08-13 15:18:13 +00:00
commit af93db7de8
4 changed files with 52 additions and 13 deletions

View file

@ -9,6 +9,7 @@ Interpreter Lock (GIL). May be possible to shift to multiprocessing in the futur
from typing import Optional, Any from typing import Optional, Any
import threading import threading
import subprocess import subprocess
import signal
import os import os
from io import TextIOWrapper from io import TextIOWrapper
import shlex import shlex
@ -160,11 +161,19 @@ class PreviewStitcher(BaseStitcher):
return True return True
return False return False
def wait(self) -> None: def wait(self, cancel: lt.deps.CancelHook) -> None:
"""Wait for this preview stitch to return.""" """Wait for this preview stitch to return.
if self.running:
with self._popen_lock: :raises InvocationCancelledError: if the action is cancelled.
self._popen_obj.wait() """
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): class FinalStitcher(BaseStitcher):
@ -272,10 +281,8 @@ class FinalStitcher(BaseStitcher):
) -> None: ) -> None:
"""Run the final stitch logging any output. """Run the final stitch logging any output.
Raises: :raises ChildProcessError: if exit code is not zero
ChildProcessError if exit code is not zero :raises InvocationCancelledError: if the action is cancelled.
InvocationCancelledError if the action is cancelled.
""" """
cmd = self.command cmd = self.command
self.logger.debug(f"Running command in subprocess: `{' '.join(cmd)}`") self.logger.debug(f"Running command in subprocess: `{' '.join(cmd)}`")

View file

@ -466,7 +466,7 @@ class SmartScanThing(lt.Thing):
self._scan_logger.info("Waiting for background processes to finish...") self._scan_logger.info("Waiting for background processes to finish...")
if self._preview_stitcher is not None: if self._preview_stitcher is not None:
self._preview_stitcher.wait() self._preview_stitcher.wait(self._cancel)
if self._scan_data.stitch_automatically: if self._scan_data.stitch_automatically:
self._scan_logger.info("Stitching final image (may take some time)...") self._scan_logger.info("Stitching final image (may take some time)...")

View file

@ -11,6 +11,9 @@ def main():
# This is used to check we catch errors correctly. # This is used to check we catch errors correctly.
if arg == "ERROR": if arg == "ERROR":
raise RuntimeError("I was told to do this.") 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) print(arg, flush=True)
time.sleep(0.2) time.sleep(0.2)

View file

@ -9,7 +9,7 @@ import os
from copy import copy from copy import copy
import uuid import uuid
import threading import threading
from time import sleep import time
import pytest import pytest
@ -200,6 +200,7 @@ def test_preview_stitching_command(caplog, mocker):
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd) mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
with caplog.at_level(logging.INFO): 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 = PreviewStitcher(FAKE_DIR, overlap=0.1, correlation_resize=0.5)
stitcher.start() stitcher.start()
# Should take a second or so to run so will still be running # 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): with pytest.raises(RuntimeError):
stitcher.start() stitcher.start()
# Wait for it to complete # Wait for it to complete
stitcher.wait() stitcher.wait(cancel_hook)
# It is now not running # It is now not running
assert not stitcher.running assert not stitcher.running
assert len(caplog.records) == 0 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): def test_final_stitching_command(caplog, mocker):
"""Check the final stitch runs until completion, and print statements are logged.""" """Check the final stitch runs until completion, and print statements are logged."""
mock_cmd = f"python {MOCK_STITCHER}" 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 = threading.Thread(target=stitcher.run, kwargs={"cancel": cancel_hook})
thread.start() thread.start()
# Sleep long enough for at least 1 log. # Sleep long enough for at least 1 log.
sleep(0.5) time.sleep(0.5)
# use set() to cancel! # use set() to cancel!
cancel_hook.set() cancel_hook.set()
thread.join() thread.join()