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:
@@ -67,7 +67,7 @@ def main() -> None:
|
|||||||
|
|
||||||
if use_gui:
|
if use_gui:
|
||||||
try:
|
try:
|
||||||
from mediahive.winmain import winmain
|
from mediahive.winmain import gui_main
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
if args.gui:
|
if args.gui:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -75,7 +75,7 @@ def main() -> None:
|
|||||||
"Install with: uv pip install mediahive[gui]"
|
"Install with: uv pip install mediahive[gui]"
|
||||||
) from exc
|
) from exc
|
||||||
else:
|
else:
|
||||||
winmain()
|
gui_main()
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.media_folders:
|
if args.media_folders:
|
||||||
|
|||||||
@@ -140,15 +140,21 @@ class RootScanner:
|
|||||||
if task and not task.done():
|
if task and not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
# Wait briefly for graceful shutdown to avoid lingering scanner tasks.
|
# Wait briefly for graceful shutdown to avoid lingering scanner tasks.
|
||||||
for task in tasks:
|
# All tasks are awaited concurrently (a slow one must not delay the
|
||||||
if task and not task.done():
|
# others), under a single overall timeout.
|
||||||
with contextlib.suppress(TimeoutError, asyncio.CancelledError):
|
pending = [task for task in tasks if task and not task.done()]
|
||||||
await asyncio.wait_for(task, timeout=2.0)
|
if pending:
|
||||||
# Best-effort persistence of scanner state.
|
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):
|
with contextlib.suppress(Exception):
|
||||||
await asyncio.to_thread(self._save_scan_state)
|
await asyncio.gather(
|
||||||
await asyncio.to_thread(self._save_reel_state)
|
asyncio.to_thread(self._save_scan_state),
|
||||||
await asyncio.to_thread(save_probe_records)
|
asyncio.to_thread(self._save_reel_state),
|
||||||
|
asyncio.to_thread(save_probe_records),
|
||||||
|
)
|
||||||
|
|
||||||
def is_scanning(self) -> bool:
|
def is_scanning(self) -> bool:
|
||||||
return self._scan_task is not None and not self._scan_task.done()
|
return self._scan_task is not None and not self._scan_task.done()
|
||||||
|
|||||||
@@ -353,6 +353,10 @@ class Supervisor:
|
|||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
for ctx in list(self._contexts.values()):
|
# Stop roots concurrently — each may wait on task cancellation and
|
||||||
await ctx.stop()
|
# 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()
|
self._contexts.clear()
|
||||||
|
|||||||
+112
-13
@@ -688,6 +688,46 @@ def _start_gamepad_remote(
|
|||||||
return thread
|
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:
|
def _setup_logging() -> Path:
|
||||||
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
|
"""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_dir.mkdir(parents=True, exist_ok=True)
|
||||||
log_path = log_dir / "mediahive.log"
|
log_path = log_dir / "mediahive.log"
|
||||||
|
|
||||||
# Rotate: keep previous run as .log.1
|
try:
|
||||||
prev = log_path.with_suffix(".log.1")
|
log_file = _rotate_and_open_log(log_path)
|
||||||
if log_path.exists():
|
except OSError:
|
||||||
if prev.exists():
|
# A previous instance still holds the log file. It is usually on its
|
||||||
prev.unlink()
|
# way out — give it a couple of seconds silently first.
|
||||||
log_path.rename(prev)
|
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)
|
if log_file is not None:
|
||||||
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
|
||||||
# Redirect raw stdout/stderr so print() and tracebacks go to the file
|
sys.stderr = log_file
|
||||||
sys.stdout = log_file
|
|
||||||
sys.stderr = log_file
|
|
||||||
|
|
||||||
# force=True removes handlers added by uvicorn/fastapi during import so that
|
# force=True removes handlers added by uvicorn/fastapi during import so that
|
||||||
# basicConfig actually takes effect (without it, it's a silent no-op)
|
# 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>
|
<div><h1>MediaHive</h1><p>Choose a folder that contains your media…</p></div>
|
||||||
</body></html>"""
|
</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:
|
class JsApi:
|
||||||
"""Python methods exposed to the frontend via window.pywebview.api."""
|
"""Python methods exposed to the frontend via window.pywebview.api."""
|
||||||
@@ -1056,4 +1155,4 @@ def winmain() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
winmain()
|
gui_main()
|
||||||
|
|||||||
Reference in New Issue
Block a user