2 Commits

Author SHA1 Message Date
Leo Vasanko
33db2c01b4 Cleaner server shutdowns:
- Remove a workaround for Sanic server not always terminating cleanly
- Terminate worker threads before server stop
- Silent closing of watching WebSocket attempted to open while shutting down
2025-08-17 08:15:01 -06:00
Leo Vasanko
26addb2f7b Image previews improved, all EXIF Orientations handled. 2025-08-17 07:00:52 -06:00
5 changed files with 44 additions and 35 deletions

View File

@@ -119,8 +119,12 @@ async def watch(req, ws):
# Send updates # Send updates
while True: while True:
await ws.send(await q.get()) await ws.send(await q.get())
except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket
raise
finally: finally:
del watching.pubsub[uuid] watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
def subscribe(uuid, ws): def subscribe(uuid, ws):

View File

@@ -43,14 +43,16 @@ async def main_start(app, loop):
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker" max_workers=workers, thread_name_prefix="cista-ioworker"
) )
await watching.start(app, loop) watching.start(app, loop)
@app.after_server_stop # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop
async def main_stop(app, loop): async def main_stop(app, loop):
quit.set() quit.set()
await watching.stop(app, loop) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
logger.debug("Cista worker threads all finished")
@app.on_request @app.on_request

View File

@@ -24,6 +24,17 @@ pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview") bp = Blueprint("preview", url_prefix="/preview")
# Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM,
5: Image.Transpose.TRANSPOSE,
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90,
}
@bp.get("/<path:path>") @bp.get("/<path:path>")
async def preview(req, path): async def preview(req, path):
@@ -69,34 +80,35 @@ def dispatch(path, quality, maxsize, maxzoom):
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
t_load_start = perf_counter() t_load = perf_counter()
img = Image.open(path) with Image.open(path) as img:
# Force decode to include I/O in load timing # Force decode to include I/O in load timing
img.load() img.load()
t_load_end = perf_counter() t_proc = perf_counter()
# Resize # Resize
orig_w, orig_h = img.size w, h = img.size
t_proc_start = perf_counter() img.thumbnail((min(w, maxsize), min(h, maxsize)))
img.thumbnail((min(orig_w, maxsize), min(orig_h, maxsize))) # Transpose pixels according to EXIF Orientation
t_proc_end = perf_counter() orientation = img.getexif().get(274, 1)
# Save as AVIF if orientation in EXIF_ORI:
imgdata = io.BytesIO() img = img.transpose(EXIF_ORI[orientation])
t_save_start = perf_counter() # Save as AVIF
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1) imgdata = io.BytesIO()
t_save_end = perf_counter() t_save = perf_counter()
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
t_end = perf_counter()
ret = imgdata.getvalue() ret = imgdata.getvalue()
load_ms = (t_load_end - t_load_start) * 1000 load_ms = (t_proc - t_load) * 1000
proc_ms = (t_proc_end - t_proc_start) * 1000 proc_ms = (t_save - t_proc) * 1000
save_ms = (t_save_end - t_save_start) * 1000 save_ms = (t_end - t_save) * 1000
logger.debug( logger.debug(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB", "Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
path.name, path.name,
load_ms, load_ms,
proc_ms, proc_ms,
save_ms, save_ms,
len(ret) / 1024,
) )
return ret return ret

View File

@@ -1,6 +1,5 @@
import os import os
import re import re
import signal
from pathlib import Path from pathlib import Path
from sanic import Sanic from sanic import Sanic
@@ -12,14 +11,6 @@ def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app from .app import app
# Set up immediate exit on Ctrl+C for faster termination
def signal_handler(signum, frame):
print("\nReceived interrupt signal, exiting immediately...")
os._exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
url, opts = parse_listen(config.config.listen) url, opts = parse_listen(config.config.listen)
# Silence Sanic's warning about running in production rather than debug # Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"

View File

@@ -440,7 +440,7 @@ def watcher_poll(loop):
quit.wait(0.1 + 8 * dur) quit.wait(0.1 + 8 * dur)
async def start(app, loop): def start(app, loop):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
@@ -454,6 +454,6 @@ async def start(app, loop):
app.ctx.watcher.start() app.ctx.watcher.start()
async def stop(app, loop): def stop(app):
quit.set() quit.set()
app.ctx.watcher.join() app.ctx.watcher.join()