Commit a419c871 authored by vertighel's avatar vertighel
Browse files

Fix event-loop blocking and missing startup frame in viewer routes



get_live_data() was called synchronously inside async route handlers
(fits_image.py's /info, /png, /panoramic, /tile, and the WS
tile-request handler) — device.matrix can block on real I/O (SDK
locks with up to 5s timeouts, or slow connection probing with no
camera attached), which froze the entire event loop, not just the
requesting client. Wrapped every call in asyncio.to_thread, matching
what stream.py's broadcast loop already did correctly.

Also: device.matrix only reflects a frame acquired in the current
process's session, so it's None right after a server restart until
the first Expose/Loop — unlike the old file read, which always showed
the last frame regardless of restarts. get_live_data() now falls back
to the last FITS file on disk when matrix is empty, restoring that
behavior while keeping the live path for everything else.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 96065e14
Loading
Loading
Loading
Loading
Loading
+39 −10
Original line number Diff line number Diff line
@@ -36,12 +36,14 @@ The section name is the ``cam_id`` used in the URL segment ``<cam_id>``.
"""

# System modules
import asyncio
import configparser
import os
from pathlib import Path

# Third-party modules
import numpy as np
from astropy.io import fits
from quart import Blueprint, request, Response, jsonify, send_file

# Custom modules
@@ -99,20 +101,47 @@ def _fits_path(cam_id: str) -> Path | None:
    return PROJECT_ROOT / DATA_FOLDER / raw_path


def _load_from_file(cam_id: str) -> np.ndarray | None:
    """Fallback: read the last FITS file written to disk for this camera.

    Used when the device has no frame in its own live buffer yet this
    process's lifetime (e.g. right after a server restart/reload, before
    the first Expose or Loop start) — without this, the viewer would show
    nothing at all even though a perfectly good last frame exists on disk.
    """

    path = _fits_path(cam_id)
    if path is None or not path.exists():
        return None
    try:
        with fits.open(path) as hdul:
            data = hdul[0].data
            if data is None:
                return None
            return data.astype(np.float32)
    except OSError:
        return None


def get_live_data(cam_id: str) -> np.ndarray | None:
    """Return a camera's current frame, read straight from the device.
    """Return a camera's current frame: live from the device if it has
    one buffered, otherwise the last frame written to disk.

    Each device's own `matrix` property already holds the last acquired
    frame in memory (or de-duplicates the fetch for network cameras like
    STX — see stx.py's _fetch()), so there's no file re-read/cache layer
    needed here anymore.
    STX — see stx.py's _fetch()) — but `matrix` only reflects frames
    acquired *this process*, so it's None until the first Expose/Loop
    since startup; the file fallback covers that gap.

    Blocking: `matrix` can do real I/O (SDK calls, or an HTTP round trip
    for STX) — callers on the asyncio event loop must run this via
    `asyncio.to_thread`, never call it directly from a route handler.
    """

    device = devices.viewer_devices.get(cam_id)
    if device is None:
        return None
    data = device.matrix if device is not None else None

    return device.matrix
    return data if data is not None else _load_from_file(cam_id)


# ---------------------------------------------------------------------------
@@ -123,7 +152,7 @@ def get_live_data(cam_id: str) -> np.ndarray | None:
async def image_info(cam_id):
    """Return shape, dtype, and auto-range statistics for the image."""

    data = get_live_data(cam_id)
    data = await asyncio.to_thread(get_live_data, cam_id)
    if data is None:
        return jsonify({'error': 'FITS not found'}), 404

@@ -143,7 +172,7 @@ async def image_info(cam_id):
async def image_png(cam_id):
    """Render the image as a PNG with Viridis colormap."""

    data = get_live_data(cam_id)
    data = await asyncio.to_thread(get_live_data, cam_id)
    if data is None:
        return Response('FITS not found', status=404)

@@ -158,7 +187,7 @@ async def image_png(cam_id):
async def image_panoramic(cam_id):
    """Render a thumbnail PNG."""

    data = get_live_data(cam_id)
    data = await asyncio.to_thread(get_live_data, cam_id)
    if data is None:
        return Response('FITS not found', status=404)

@@ -179,7 +208,7 @@ async def image_tile(cam_id):
    wire, no precision loss.
    """

    data = get_live_data(cam_id)
    data = await asyncio.to_thread(get_live_data, cam_id)
    if data is None:
        return Response('FITS not found', status=404)

+4 −1
Original line number Diff line number Diff line
@@ -206,7 +206,10 @@ async def _handle_tile_request(ws_conn, msg: dict) -> None:
    ry     = int(msg.get("ry", 50))
    seq    = msg.get("seq", 0)

    data = get_live_data(cam_id)
    # get_live_data can block on real I/O (SDK calls, or an HTTP round
    # trip for STX) — offload it so a slow device doesn't stall the
    # whole event loop (every other WebSocket client, every other camera).
    data = await asyncio.to_thread(get_live_data, cam_id)
    if data is None:
        return

+6 −4
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ from astropy.io import fits
from noctua import devices
from noctua.api.basecontext import resource_registry
from noctua.api.deps import dep_checker
from noctua.api.fits_image import get_live_data
from noctua.utils.image import array_to_png, fits_to_png
from noctua.utils.logger import log
from noctua.utils.structure import current_log_path
@@ -342,10 +343,11 @@ class StreamManager:
        """Broadcast an immediate one-shot preview (e.g. on a 'refresh' WS request)."""

        try:
            device = devices.viewer_devices.get(cam_id)
            if device is None:
                return
            data = await asyncio.to_thread(getattr, device, 'matrix')
            # get_live_data falls back to the last file on disk when the
            # device has no frame buffered yet this process's lifetime
            # (e.g. right after a server restart, before the first
            # Expose/Loop) — matches the REST /png etc. routes.
            data = await asyncio.to_thread(get_live_data, cam_id)
            if data is not None:
                await self._broadcast_preview(cam_id, data)
        except Exception as e: