feat: graceful shutdown with store persistence and restart overlay
Lint & Test / test (push) Failing after 29s

- Add /api/v1/system/shutdown endpoint that triggers clean uvicorn exit
- Persist all 15 stores to disk during shutdown via _save_all_stores()
- Add force parameter to BaseJsonStore._save() to bypass restore freeze
- Restart script now requests graceful shutdown via API (15s timeout),
  falls back to force-kill only if server doesn't exit in time
- Broadcast server_restarting event over WebSocket before shutdown
- Frontend shows "Server restarting..." overlay instantly on WS event,
  replacing the old dynamically-created overlay from settings.ts
- Add server_ref module to share uvicorn Server + TrayManager refs
- Add i18n keys for restart overlay (en/ru/zh)
This commit is contained in:
2026-03-24 15:50:32 +03:00
parent 73947eb6cb
commit 9b4dbac088
14 changed files with 242 additions and 72 deletions
+59
View File
@@ -0,0 +1,59 @@
"""Module-level holder for the uvicorn Server and TrayManager references.
Allows the shutdown API endpoint to trigger graceful shutdown via
``server.should_exit = True`` + ``tray.stop()``, which is the same
mechanism the system tray "Shutdown" menu item uses.
"""
from typing import Any, Optional
_server: Optional[Any] = None # uvicorn.Server
_tray: Optional[Any] = None # TrayManager
def set_server(server: Any) -> None:
"""Store the uvicorn Server instance (called from __main__)."""
global _server
_server = server
def set_tray(tray: Any) -> None:
"""Store the TrayManager instance (called from __main__)."""
global _tray
_tray = tray
def request_shutdown() -> None:
"""Signal uvicorn + tray to perform a graceful shutdown.
Broadcasts a ``server_restarting`` event so the frontend can show
a restart indicator, persists all stores to disk, then sets
``should_exit = True`` on the uvicorn Server and stops the tray.
"""
# Notify connected clients that a restart is in progress
_broadcast_restarting()
# Persist stores before signaling shutdown.
# The lifespan shutdown handler also saves, but it may not run
# reliably when uvicorn is in a daemon thread.
try:
from wled_controller.main import _save_all_stores
_save_all_stores()
except Exception:
pass # best-effort; lifespan handler is the backup
if _server is not None:
_server.should_exit = True
if _tray is not None:
_tray.stop()
def _broadcast_restarting() -> None:
"""Push a server_restarting event to all connected WebSocket clients."""
try:
from wled_controller.api.dependencies import _deps
pm = _deps.get("processor_manager")
if pm is not None:
pm.fire_event({"type": "server_restarting"})
except Exception:
pass