Faster GUI exit, graceful second-instance startup, pretty crash window

- scanner.stop: await cancelled tasks concurrently under one 2s budget
  and persist scan/reel/probe state concurrently (was sequential)
- supervisor.shutdown: stop root contexts concurrently (per-root scanner
  waits and network-mount snapshot flushes no longer add up)
- winmain: retry log-file rotation while a previous instance still holds
  mediahive.log (Windows denies rename of an open file — the old instance
  dying slowly made new instances crash with a PermissionError); after 2s
  show a 'waiting for previous instance' window, retry up to 15s, then
  fall back to a per-pid log file
- winmain: gui_main() wraps winmain() and renders unhandled exceptions as
  a TraceRite HTML page in a pywebview window instead of PyInstaller's
  plain-text fatal-error dialog; __main__ GUI mode uses it too
This commit is contained in:
2026-09-09 03:46:27 +00:00
parent d1f1b9ecb8
commit e5300eaac0
4 changed files with 134 additions and 25 deletions
+2 -2
View File
@@ -67,7 +67,7 @@ def main() -> None:
if use_gui:
try:
from mediahive.winmain import winmain
from mediahive.winmain import gui_main
except ImportError as exc:
if args.gui:
raise RuntimeError(
@@ -75,7 +75,7 @@ def main() -> None:
"Install with: uv pip install mediahive[gui]"
) from exc
else:
winmain()
gui_main()
return
if args.media_folders:
+14 -8
View File
@@ -140,15 +140,21 @@ class RootScanner:
if task and not task.done():
task.cancel()
# Wait briefly for graceful shutdown to avoid lingering scanner tasks.
for task in tasks:
if task and not task.done():
with contextlib.suppress(TimeoutError, asyncio.CancelledError):
await asyncio.wait_for(task, timeout=2.0)
# Best-effort persistence of scanner state.
# All tasks are awaited concurrently (a slow one must not delay the
# others), under a single overall timeout.
pending = [task for task in tasks if task and not task.done()]
if pending:
with contextlib.suppress(TimeoutError, asyncio.CancelledError):
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.to_thread(self._save_scan_state)
await asyncio.to_thread(self._save_reel_state)
await asyncio.to_thread(save_probe_records)
await asyncio.gather(
asyncio.to_thread(self._save_scan_state),
asyncio.to_thread(self._save_reel_state),
asyncio.to_thread(save_probe_records),
)
def is_scanning(self) -> bool:
return self._scan_task is not None and not self._scan_task.done()
+6 -2
View File
@@ -353,6 +353,10 @@ class Supervisor:
async def shutdown(self) -> None:
async with self._lock:
for ctx in list(self._contexts.values()):
await ctx.stop()
# Stop roots concurrently — each may wait on task cancellation and
# network-mount snapshot flushes, and those delays must not add up.
await asyncio.gather(
*(ctx.stop() for ctx in list(self._contexts.values())),
return_exceptions=True,
)
self._contexts.clear()
+112 -13
View File
@@ -688,6 +688,46 @@ def _start_gamepad_remote(
return thread
def _rotate_and_open_log(log_path: Path):
"""Rotate mediahive.log to .log.1 and open a fresh log file.
Raises OSError when a previous MediaHive instance still holds the file
open (Windows forbids renaming a file that is open without delete
sharing) — callers treat that as "previous instance not dead yet".
"""
prev = log_path.with_suffix(".log.1")
if log_path.exists():
if prev.exists():
prev.unlink()
log_path.rename(prev)
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
return os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered
def _wait_for_previous_instance(log_path: Path, timeout: float = 15.0):
"""Show a waiting notice while a previous MediaHive instance exits.
Returns an open log file handle, or None on timeout.
"""
result: list = []
window = webview.create_window(
"MediaHive", html=_WAIT_HTML, width=520, height=280, resizable=False
)
def poll() -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
result.append(_rotate_and_open_log(log_path))
break
except OSError:
time.sleep(0.5)
window.destroy()
webview.start(func=poll, icon=_icon_path(), **_webview_start_kwargs())
return result[0] if result else None
def _setup_logging() -> Path:
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
@@ -702,19 +742,30 @@ def _setup_logging() -> Path:
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / "mediahive.log"
# Rotate: keep previous run as .log.1
prev = log_path.with_suffix(".log.1")
if log_path.exists():
if prev.exists():
prev.unlink()
log_path.rename(prev)
try:
log_file = _rotate_and_open_log(log_path)
except OSError:
# A previous instance still holds the log file. It is usually on its
# way out — give it a couple of seconds silently first.
log_file = None
deadline = time.monotonic() + 2.0
while log_file is None and time.monotonic() < deadline:
time.sleep(0.25)
with contextlib.suppress(OSError):
log_file = _rotate_and_open_log(log_path)
if log_file is None:
log_file = _wait_for_previous_instance(log_path)
if log_file is None:
# Never fail startup over logging: fall back to a per-process file.
log_path = log_dir / f"mediahive-{os.getpid()}.log"
with contextlib.suppress(OSError):
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered
# Redirect raw stdout/stderr so print() and tracebacks go to the file
sys.stdout = log_file
sys.stderr = log_file
if log_file is not None:
# Redirect raw stdout/stderr so print() and tracebacks go to the file
sys.stdout = log_file
sys.stderr = log_file
# force=True removes handlers added by uvicorn/fastapi during import so that
# basicConfig actually takes effect (without it, it's a silent no-op)
@@ -743,6 +794,54 @@ _SETUP_HTML = """<!DOCTYPE html>
<div><h1>MediaHive</h1><p>Choose a folder that contains your media…</p></div>
</body></html>"""
# Shown when a previous instance is still shutting down.
_WAIT_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #141414; color: #fff;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; align-items: center; justify-content: center;
height: 100vh; text-align: center; }
h1 { font-size: 2rem; color: #e50914; margin-bottom: .5rem; }
p { color: #aaa; }
</style></head><body>
<div><h1>MediaHive</h1>
<p>Waiting for the previous MediaHive instance to finish exiting…</p></div>
</body></html>"""
def _show_fatal_error(exc: BaseException) -> None:
"""Show an unhandled exception as a TraceRite HTML page in a webview.
Frozen --windowed builds otherwise surface crashes only as PyInstaller's
plain-text error dialog (or nothing at all).
"""
try:
from tracerite.html import html_traceback
fragment = str(html_traceback(exc))
except Exception: # noqa: BLE001 - error reporting must never raise
return
page = (
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<title>MediaHive — Error</title></head>"
f"<body style='margin:1.5rem'>{fragment}</body></html>"
)
try:
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
webview.start(icon=_icon_path(), **_webview_start_kwargs())
except Exception:
logger.exception("Could not display the error window")
def gui_main() -> None:
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
try:
winmain()
except Exception as exc:
logger.exception("Fatal error")
_show_fatal_error(exc)
class JsApi:
"""Python methods exposed to the frontend via window.pywebview.api."""
@@ -1056,4 +1155,4 @@ def winmain() -> None:
if __name__ == "__main__":
winmain()
gui_main()