Working dev reload on Windows; fast quiet scanner shutdown
uvicorn's reloader is unusable on Windows for this app: it restarts the server child via CTRL_C_EVENT, which is never delivered to a plain spawn child (no console process group of its own), so the child kept running old code and the reloader blocked in process.join() after the first reload — the scanner included. And when a child does restart, uvicorn hands it parent-bound sockets that ProactorEventLoop cannot register with IOCP (WinError 87 on accept), while the selector loop would break asyncio subprocesses (ffmpeg/ffprobe showreel generation). In dev mode on Windows, mediahive now runs a small supervisor instead: it watches the package with watchfiles and respawns a fresh child process (which binds its own sockets) on every change, and terminates the child on Ctrl-C. POSIX keeps uvicorn's native reload. Shutdown: RootScanner.stop() no longer does a final best-effort state save — those to_thread writes ran on the asyncio default executor, whose non-daemon threads blocked interpreter exit, so log lines appeared after the shell prompt returned. All scanner state is already persisted after each completed scan and when the showreel queue drains. Shutdown-time log lines (scan cancelled, showreel worker stopping) move to debug.
This commit is contained in:
@@ -33,6 +33,54 @@ def _derive_name(path: str) -> str:
|
||||
return p.name or p.anchor.strip("/\\").lower() or "media"
|
||||
|
||||
|
||||
def _dev_reload_supervisor() -> None:
|
||||
"""Windows dev-mode reloader: restart the server process on changes.
|
||||
|
||||
uvicorn's own reload cannot work here: it restarts the child with
|
||||
CTRL_C_EVENT, which is never delivered to a plain spawn child (no own
|
||||
console process group), so the reloader blocks in join() after the
|
||||
first reload and the old server — scanner included — keeps running.
|
||||
And even when the child does restart, uvicorn passes it sockets bound
|
||||
by the parent; ProactorEventLoop cannot register inherited sockets
|
||||
with IOCP (WinError 87 on accept), while the selector loop would lose
|
||||
asyncio subprocess support (ffmpeg/ffprobe showreel generation).
|
||||
|
||||
So: watch the package directory ourselves and respawn a fresh child
|
||||
process that binds its own sockets. The child runs with
|
||||
MEDIAHIVE_DEV_CHILD=1 and reload disabled. Scanner state is persisted
|
||||
after every scan, so a non-graceful child exit on reload loses nothing.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
import watchfiles
|
||||
|
||||
watch_dir = Path(__file__).parent
|
||||
argv = [sys.executable, "-m", "mediahive", *sys.argv[1:]]
|
||||
child_env = dict(os.environ, MEDIAHIVE_DEV_CHILD="1")
|
||||
|
||||
print(f"Dev reloader: watching {watch_dir}", file=sys.stderr)
|
||||
proc = subprocess.Popen(argv, env=child_env)
|
||||
try:
|
||||
for changes in watchfiles.watch(watch_dir):
|
||||
changed = sorted({str(Path(p).name) for _, p in changes})
|
||||
print(
|
||||
f"Dev reloader: change in {', '.join(changed[:5])} — restarting",
|
||||
file=sys.stderr,
|
||||
)
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
proc = subprocess.Popen(argv, env=child_env)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_configure_windows_event_loop_policy()
|
||||
|
||||
@@ -94,6 +142,14 @@ def main() -> None:
|
||||
roots[name] = p.as_posix()
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
||||
|
||||
if (
|
||||
DEVMODE
|
||||
and sys.platform == "win32"
|
||||
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
||||
):
|
||||
_dev_reload_supervisor()
|
||||
return
|
||||
|
||||
server.run(
|
||||
"mediahive.server:app",
|
||||
listen=args.listen,
|
||||
|
||||
@@ -163,13 +163,10 @@ class RootScanner:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*pending, return_exceptions=True), timeout=2.0
|
||||
)
|
||||
# Best-effort persistence of scanner state, concurrently.
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.gather(
|
||||
asyncio.to_thread(self._save_scan_state),
|
||||
asyncio.to_thread(self._save_reel_state),
|
||||
asyncio.to_thread(save_probe_records),
|
||||
)
|
||||
# No final state save on shutdown: state is persisted after every
|
||||
# completed scan, and a last-minute save over a network drive only
|
||||
# delays process exit (executor threads block interpreter shutdown,
|
||||
# so log lines appear after the shell prompt has returned).
|
||||
|
||||
def is_scanning(self) -> bool:
|
||||
return self._scan_task is not None and not self._scan_task.done()
|
||||
@@ -1115,7 +1112,7 @@ class RootScanner:
|
||||
)
|
||||
)
|
||||
)
|
||||
logger.info("Scan cancelled (%s)", task_id)
|
||||
logger.debug("Scan cancelled (%s)", task_id)
|
||||
except Exception:
|
||||
logger.exception("Scan failed (%s)", task_id)
|
||||
await self._send(
|
||||
@@ -1343,7 +1340,7 @@ class RootScanner:
|
||||
await asyncio.to_thread(self._save_reel_state)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Showreel worker shutting down for root %s", self.root_id)
|
||||
logger.debug("Showreel worker shutting down for root %s", self.root_id)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
||||
Reference in New Issue
Block a user