From 33db2c01b4007e0f7e9fab94747928c600911df8 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 17 Aug 2025 07:21:03 -0600 Subject: [PATCH] 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 --- cista/api.py | 6 +++++- cista/app.py | 8 +++++--- cista/serve.py | 9 --------- cista/watching.py | 4 ++-- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/cista/api.py b/cista/api.py index ca097ea..8f236cb 100644 --- a/cista/api.py +++ b/cista/api.py @@ -119,8 +119,12 @@ async def watch(req, ws): # Send updates while True: 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: - del watching.pubsub[uuid] + watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not def subscribe(uuid, ws): diff --git a/cista/app.py b/cista/app.py index a87cef1..25f5066 100644 --- a/cista/app.py +++ b/cista/app.py @@ -43,14 +43,16 @@ async def main_start(app, loop): app.ctx.threadexec = ThreadPoolExecutor( 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): quit.set() - await watching.stop(app, loop) + watching.stop(app) app.ctx.threadexec.shutdown() + logger.debug("Cista worker threads all finished") @app.on_request diff --git a/cista/serve.py b/cista/serve.py index 5e2a024..280ba31 100644 --- a/cista/serve.py +++ b/cista/serve.py @@ -1,6 +1,5 @@ import os import re -import signal from pathlib import Path from sanic import Sanic @@ -12,14 +11,6 @@ def run(*, dev=False): """Run Sanic main process that spawns worker processes to serve HTTP requests.""" 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) # Silence Sanic's warning about running in production rather than debug os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" diff --git a/cista/watching.py b/cista/watching.py index e9eef85..094b088 100644 --- a/cista/watching.py +++ b/cista/watching.py @@ -440,7 +440,7 @@ def watcher_poll(loop): quit.wait(0.1 + 8 * dur) -async def start(app, loop): +def start(app, loop): global rootpath config.load_config() rootpath = config.config.path @@ -454,6 +454,6 @@ async def start(app, loop): app.ctx.watcher.start() -async def stop(app, loop): +def stop(app): quit.set() app.ctx.watcher.join()