Files
csaxs_bec/tests/e2e/_queue_helpers.py
T
Mirko HollerandClaude Sonnet 5 ff50711dc9 test(flomni): add tomo-queue e2e harness against the simulated flOMNI
Sections A-C of TOMO_QUEUE_TESTING.md's checklist, run against a live sim
session: params-restored-per-job, legacy queue migration, empty/all-done
no-ops, start_index semantics, exception/SIGKILL crash-resume, resume-
before-fresh ordering, and concurrent queue edits from a second client.
All 10 tests pass together with no cross-test interference.

The flomni_sim fixture and _bootstrap.py factor out what it takes to
construct a live Flomni against the sim (builtins/reload bootstrap,
side-effect neutralization, RT-feedback/fsamx setup) for reuse by both
in-process tests and the SIGKILL-based subprocess tests.

Also adds the AI_docs/ handoff docs (testing checklist results, command-
jobs plan with the action-registry decisions) that this work updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:25:33 +02:00

121 lines
4.3 KiB
Python

"""Shared helpers for driving tomo_queue_execute() against the live sim."""
from __future__ import annotations
import subprocess
import sys
import threading
import time
from pathlib import Path
_SUBPROCESS_SCRIPT = Path(__file__).with_name("_run_queue_subprocess.py")
SHORT_SCAN_PARAMS = dict(
# Single acquisition per angle instead of a full Fermat grid -- makes a
# queued "tomogram" fast enough to run in a test.
single_point_instead_of_fermat_scan=True,
tomo_countingtime=0.05,
frames_per_trigger=1,
fovx=2,
fovy=2,
tomo_shellstep=1,
tomo_angle_range=180,
zero_deg_reference_at_each_subtomo=False,
)
# N = int(tomo_angle_range / tomo_angle_stepsize) = 1 -> 8 projections total,
# the fastest a real tomogram can be for tests that don't care about the
# per-job N itself, just that a job runs cleanly.
FAST_STEPSIZE = 170.0
def short_params(flomni, tomo_angle_stepsize: float = FAST_STEPSIZE) -> dict:
for name, value in SHORT_SCAN_PARAMS.items():
setattr(flomni, name, value)
flomni.tomo_angle_stepsize = tomo_angle_stepsize
return {name: getattr(flomni, name) for name in flomni._TOMO_SCAN_PARAM_NAMES}
def add_short_job(flomni, label: str, tomo_angle_stepsize: float = FAST_STEPSIZE) -> int:
short_params(flomni, tomo_angle_stepsize)
return flomni.tomo_queue_add(label)
class ProgressSampler:
"""Samples ``flomni.progress[key]`` on a background thread while the
caller runs something blocking (e.g. ``tomo_queue_execute()``) in the
foreground.
``tomo_queue_execute()`` itself must stay on the main thread:
BECIPythonClient's live-update machinery installs a SIGINT handler per
scan request (``ipython_live_updates.process_request``), and Python only
allows ``signal.signal()`` from the main thread of the main interpreter
-- calling it from a worker thread raises ``ValueError``. So it's the
*sampling*, not the execution, that runs in the background here.
"""
def __init__(self, flomni, key: str = "subtomo_total_projections", interval: float = 0.05):
self._flomni = flomni
self._key = key
self._interval = interval
self._observations: list = []
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def _run(self):
while not self._stop.is_set():
self._observations.append(self._flomni.progress.get(self._key))
time.sleep(self._interval)
def __enter__(self) -> "ProgressSampler":
self._thread.start()
return self
def __exit__(self, *exc_info) -> None:
self._stop.set()
self._thread.join(timeout=5)
@property
def observations(self) -> list:
return list(self._observations)
def first_index_of(self, value) -> int | None:
try:
return self._observations.index(value)
except ValueError:
return None
def spawn_queue_subprocess(
services_config_path, log_path, start_index: int = 0
) -> subprocess.Popen:
"""Launch _run_queue_subprocess.py as a real OS process against the same
sim, so it can be SIGKILLed -- or run concurrently with a second client
editing the queue -- the way a real crashed/second kernel would be.
Output goes to ``log_path``, not a PIPE: a killed/long-running subprocess
whose stdout pipe nobody drains can deadlock once the OS pipe buffer
fills (tomo_queue_execute() prints a lot -- progress bars per move).
"""
with open(log_path, "w") as log_file:
# Popen dup()s the fd for the child; safe to close our copy right
# after spawning instead of leaking it for the subprocess's lifetime.
return subprocess.Popen(
[sys.executable, str(_SUBPROCESS_SCRIPT), str(services_config_path), str(start_index)],
stdout=log_file,
stderr=subprocess.STDOUT,
)
def wait_until(predicate, timeout: float, interval: float = 0.1):
"""Poll ``predicate()`` until it returns a truthy value or ``timeout``
elapses. Returns the truthy value, or raises TimeoutError.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = predicate()
if result:
return result
time.sleep(interval)
raise TimeoutError(f"condition not met within {timeout}s")