fix(shutdown): apply target stop actions before tearing down HA/MQTT

Reorder the lifespan shutdown so processor_manager.stop_all() runs before
ha_manager.shutdown(), mqtt_manager.shutdown(), and mqtt_service.stop().
HA-light targets check `_ha_runtime.is_connected` before applying their
`stop_action` (turn_off / restore) and silently skip when HA is already
disconnected; MQTT-output devices need the broker connection alive to
send restore frames. The previous order tore those down first, turning
"stop_targets" into a no-op for those targets — most visible when
closing via the tray Shutdown button.

Also moves automation_engine.stop(), discovery_watcher.stop(), and the
OS notification listener stop ahead of processor stop so they can no
longer fire events into a shutting-down processor manager. Independent
services (weather, update checker, auto-backup) now run last, where
their order does not matter.

Bonus: if the daemon-thread join times out (10 s) and the rest of
shutdown is cut short, the user-visible part — targets stopping — has
already run.
This commit is contained in:
2026-05-10 22:39:18 +03:00
parent 0f5850ef80
commit 6a07a6b1a2
+33 -31
View File
@@ -394,52 +394,34 @@ async def lifespan(app: FastAPI):
# where no CRUD happened during the session. # where no CRUD happened during the session.
_save_all_stores() _save_all_stores()
# Stop Home Assistant manager # Stop automation engine first so it can no longer activate scenes that
try: # would talk to processors mid-shutdown.
await ha_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping Home Assistant manager: {e}")
# Stop weather manager
try:
weather_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping weather manager: {e}")
# Stop update checker
try:
await update_service.stop()
except Exception as e:
logger.error(f"Error stopping update checker: {e}")
# Stop auto-backup engine
try:
await auto_backup_engine.stop()
except Exception as e:
logger.error(f"Error stopping auto-backup engine: {e}")
# Stop automation engine first (deactivates automation-managed scenes)
try: try:
await automation_engine.stop() await automation_engine.stop()
logger.info("Stopped automation engine") logger.info("Stopped automation engine")
except Exception as e: except Exception as e:
logger.error(f"Error stopping automation engine: {e}") logger.error(f"Error stopping automation engine: {e}")
# Stop discovery watcher (before health monitor stop so events still flow) # Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None: if discovery_watcher is not None:
try: try:
await discovery_watcher.stop() await discovery_watcher.stop()
except Exception as e: except Exception as e:
logger.error(f"Error stopping discovery watcher: {e}") logger.error(f"Error stopping discovery watcher: {e}")
# Stop OS notification listener
try: try:
os_notif_listener.stop() os_notif_listener.stop()
except Exception as e: except Exception as e:
logger.error(f"Error stopping OS notification listener: {e}") logger.error(f"Error stopping OS notification listener: {e}")
# Stop all processing. # Stop all processing BEFORE tearing down ha_manager / mqtt_manager /
# The shutdown action setting controls whether per-device restore # mqtt_service. HA-light targets need a live HA runtime to apply their
# stop_action (turn_off / restore), and MQTT-output devices need a live
# MQTT broker connection to send restore frames. Shutting those down
# first silently turns "stop_targets" into a no-op for those targets.
#
# The shutdown_action setting controls whether per-device restore
# frames are sent: "stop_targets" (default) runs the normal stop # frames are sent: "stop_targets" (default) runs the normal stop
# sequence; "nothing" cancels capture tasks so the LEDs freeze on # sequence; "nothing" cancels capture tasks so the LEDs freeze on
# their last frame. # their last frame.
@@ -458,18 +440,38 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Error stopping processors: {e}") logger.error(f"Error stopping processors: {e}")
# Stop MQTT manager (entity-based broker connections) # Now safe to tear down the connections that processors depended on.
try:
await ha_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping Home Assistant manager: {e}")
try: try:
await mqtt_manager.shutdown() await mqtt_manager.shutdown()
except Exception as e: except Exception as e:
logger.error(f"Error stopping MQTT manager: {e}") logger.error(f"Error stopping MQTT manager: {e}")
# Stop MQTT service (legacy global connection)
try: try:
await mqtt_service.stop() await mqtt_service.stop()
except Exception as e: except Exception as e:
logger.error(f"Error stopping MQTT service: {e}") logger.error(f"Error stopping MQTT service: {e}")
# Independent services — order doesn't matter relative to processors.
try:
weather_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping weather manager: {e}")
try:
await update_service.stop()
except Exception as e:
logger.error(f"Error stopping update checker: {e}")
try:
await auto_backup_engine.stop()
except Exception as e:
logger.error(f"Error stopping auto-backup engine: {e}")
# Create FastAPI application # Create FastAPI application
app = FastAPI( app = FastAPI(