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:
2026-09-09 15:15:27 +00:00
parent ff6b195973
commit e6eadb2ecd
2 changed files with 62 additions and 9 deletions
+56
View File
@@ -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,
+6 -9
View File
@@ -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(