From 2edd7425b356cb6ee17884486d06a0bb4e778e3c Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 13 Mar 2026 17:25:34 +0100 Subject: [PATCH] Make core.shutdown() reentrant and remove monitor state guard Make Core.shutdown() reentrant using an asyncio.Event so that concurrent callers await the in-progress shutdown instead of starting a second one. This ensures the inhibitor lock is held until shutdown truly completes, even if PrepareForShutdown fires while an API-initiated shutdown is already in progress. The state guard in the monitor task is no longer needed since core.shutdown() now handles reentrance itself. Co-Authored-By: Claude Opus 4.6 --- supervisor/core.py | 42 ++++++++++++++++++++++++-------------- supervisor/host/manager.py | 10 +-------- tests/host/test_manager.py | 21 +++++++++++-------- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/supervisor/core.py b/supervisor/core.py index 121d21681..6e94f4ca0 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -42,6 +42,7 @@ class Core(CoreSysAttributes): self.coresys: CoreSys = coresys self._state: CoreState = CoreState.INITIALIZE self.exit_code: int = 0 + self._shutdown_event: asyncio.Event = asyncio.Event() @property def state(self) -> CoreState: @@ -353,28 +354,39 @@ class Core(CoreSysAttributes): self.sys_loop.stop() async def shutdown(self, *, remove_homeassistant_container: bool = False) -> None: - """Shutdown all running containers in correct order.""" + """Shutdown all running containers in correct order. + + Reentrant: if a shutdown is already in progress, subsequent calls + await completion of the existing shutdown rather than starting a second one. + """ + if self.state in (CoreState.SHUTDOWN, CoreState.STOPPING, CoreState.CLOSE): + await self._shutdown_event.wait() + return + # don't process scheduler anymore if self.state == CoreState.RUNNING: await self.set_state(CoreState.SHUTDOWN) - # Shutdown Application Add-ons, using Home Assistant API - await self.sys_addons.shutdown(AddonStartup.APPLICATION) + try: + # Shutdown Application Add-ons, using Home Assistant API + await self.sys_addons.shutdown(AddonStartup.APPLICATION) - # Close Home Assistant - with suppress(HassioError): - await self.sys_homeassistant.core.stop( - remove_container=remove_homeassistant_container - ) + # Close Home Assistant + with suppress(HassioError): + await self.sys_homeassistant.core.stop( + remove_container=remove_homeassistant_container + ) - # Shutdown System Add-ons - await self.sys_addons.shutdown(AddonStartup.SERVICES) - await self.sys_addons.shutdown(AddonStartup.SYSTEM) - await self.sys_addons.shutdown(AddonStartup.INITIALIZE) + # Shutdown System Add-ons + await self.sys_addons.shutdown(AddonStartup.SERVICES) + await self.sys_addons.shutdown(AddonStartup.SYSTEM) + await self.sys_addons.shutdown(AddonStartup.INITIALIZE) - # Shutdown all Plugins - if self.state in (CoreState.STOPPING, CoreState.SHUTDOWN): - await self.sys_plugins.shutdown() + # Shutdown all Plugins + if self.state in (CoreState.STOPPING, CoreState.SHUTDOWN): + await self.sys_plugins.shutdown() + finally: + self._shutdown_event.set() async def _update_last_boot(self) -> None: """Update last boot time.""" diff --git a/supervisor/host/manager.py b/supervisor/host/manager.py index 6edfa0fec..036e92e6b 100644 --- a/supervisor/host/manager.py +++ b/supervisor/host/manager.py @@ -9,7 +9,7 @@ from typing import Self from awesomeversion import AwesomeVersion -from ..const import BusEvent, CoreState +from ..const import BusEvent from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ( DBusError, @@ -233,14 +233,6 @@ class HostManager(CoreSysAttributes): if not active: continue - # Only handle if Supervisor didn't initiate the shutdown - if self.sys_core.state != CoreState.RUNNING: - _LOGGER.debug( - "PrepareForShutdown received but already in state %s", - self.sys_core.state, - ) - break - _LOGGER.info( "Host shutdown/reboot detected, gracefully stopping services" ) diff --git a/tests/host/test_manager.py b/tests/host/test_manager.py index 87d2f8033..b5264dfb6 100644 --- a/tests/host/test_manager.py +++ b/tests/host/test_manager.py @@ -1,7 +1,7 @@ """Test host manager.""" import asyncio -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from awesomeversion import AwesomeVersion import pytest @@ -109,22 +109,25 @@ async def test_host_shutdown_on_prepare_for_shutdown_signal( await shutdown_called.wait() -async def test_host_shutdown_signal_ignored_when_not_running( +async def test_host_shutdown_signal_reentrant( coresys: CoreSys, logind_service: LogindService ): - """Test PrepareForShutdown is ignored if Supervisor already shutting down.""" + """Test PrepareForShutdown during in-progress shutdown awaits same shutdown.""" + shutdown_called = asyncio.Event() + + async def mock_shutdown(**kwargs): + shutdown_called.set() + await coresys.host.load() await coresys.core.set_state(CoreState.SHUTDOWN) # Give the monitor task time to start and register the signal listener await asyncio.sleep(0.1) - with patch.object( - coresys.core, "shutdown", new_callable=AsyncMock - ) as mock_shutdown: + with patch.object(coresys.core, "shutdown", side_effect=mock_shutdown): logind_service.PrepareForShutdown() await logind_service.ping() - # Give the monitor task time to process the signal - await asyncio.sleep(0.1) - mock_shutdown.assert_not_called() + # shutdown() is called reentrantly - it awaits the in-progress shutdown + async with asyncio.timeout(2): + await shutdown_called.wait()