diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index c7cfe4c28..9894dd2d7 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -140,9 +140,7 @@ class Addon(AddonModel): super().__init__(coresys, slug) self.instance: DockerAddon = DockerAddon(coresys, self) self._state: AddonState = AddonState.UNKNOWN - self._manual_stop: bool = ( - self.sys_hardware.helper.last_boot != self.sys_config.last_boot - ) + self._manual_stop: bool = False self._listeners: list[EventListener] = [] self._startup_event = asyncio.Event() self._startup_task: asyncio.Task | None = None @@ -216,6 +214,10 @@ class Addon(AddonModel): async def load(self) -> None: """Async initialize of object.""" + self._manual_stop = ( + await self.sys_hardware.helper.last_boot() != self.sys_config.last_boot + ) + if self.is_detached: await super().refresh_path_cache() @@ -720,7 +722,7 @@ class Addon(AddonModel): try: options = self.schema.validate(self.options) - write_json_file(self.path_options, options) + await self.sys_run_in_executor(write_json_file, self.path_options, options) except vol.Invalid as ex: _LOGGER.error( "Add-on %s has invalid options: %s", @@ -938,19 +940,20 @@ class Addon(AddonModel): ) return out - def write_pulse(self) -> None: + async def write_pulse(self) -> None: """Write asound config to file and return True on success.""" pulse_config = self.sys_plugins.audio.pulse_client( input_profile=self.audio_input, output_profile=self.audio_output ) - # Cleanup wrong maps - if self.path_pulse.is_dir(): - shutil.rmtree(self.path_pulse, ignore_errors=True) - - # Write pulse config - try: + def write_pulse_config(): + # Cleanup wrong maps + if self.path_pulse.is_dir(): + shutil.rmtree(self.path_pulse, ignore_errors=True) self.path_pulse.write_text(pulse_config, encoding="utf-8") + + try: + await self.sys_run_in_executor(write_pulse_config) except OSError as err: if err.errno == errno.EBADMSG: self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE @@ -1070,7 +1073,7 @@ class Addon(AddonModel): # Sound if self.with_audio: - self.write_pulse() + await self.write_pulse() def _check_addon_config_dir(): if self.path_config.is_dir(): diff --git a/supervisor/arch.py b/supervisor/arch.py index b36e7e84f..dde7d9b8f 100644 --- a/supervisor/arch.py +++ b/supervisor/arch.py @@ -50,7 +50,7 @@ class CpuArch(CoreSysAttributes): async def load(self) -> None: """Load data and initialize default arch.""" try: - arch_data = read_json_file(ARCH_JSON) + arch_data = await self.sys_run_in_executor(read_json_file, ARCH_JSON) except ConfigurationFileError: _LOGGER.warning("Can't read arch json file from %s", ARCH_JSON) return diff --git a/supervisor/core.py b/supervisor/core.py index 913c4c8aa..cc4c1f3ce 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -223,7 +223,7 @@ class Core(CoreSysAttributes): try: # HomeAssistant is already running, only Supervisor restarted - if self.sys_hardware.helper.last_boot == self.sys_config.last_boot: + if await self.sys_hardware.helper.last_boot() == self.sys_config.last_boot: _LOGGER.info("Detected Supervisor restart") return @@ -362,7 +362,7 @@ class Core(CoreSysAttributes): async def _update_last_boot(self): """Update last boot time.""" - self.sys_config.last_boot = self.sys_hardware.helper.last_boot + self.sys_config.last_boot = await self.sys_hardware.helper.last_boot() await self.sys_config.save_data() async def _retrieve_whoami(self, with_ssl: bool) -> WhoamiData | None: diff --git a/supervisor/hardware/helper.py b/supervisor/hardware/helper.py index bc1d539d1..f1a71c49d 100644 --- a/supervisor/hardware/helper.py +++ b/supervisor/hardware/helper.py @@ -25,6 +25,7 @@ class HwHelper(CoreSysAttributes): def __init__(self, coresys: CoreSys): """Init hardware object.""" self.coresys = coresys + self._last_boot: datetime | None = None @property def support_audio(self) -> bool: @@ -41,11 +42,15 @@ class HwHelper(CoreSysAttributes): """Return True if the device have USB ports.""" return bool(self.sys_hardware.filter_devices(subsystem=UdevSubsystem.USB)) - @property - def last_boot(self) -> datetime | None: + async def last_boot(self) -> datetime | None: """Return last boot time.""" + if self._last_boot: + return self._last_boot + try: - stats: str = _PROC_STAT.read_text(encoding="utf-8") + stats: str = await self.sys_run_in_executor( + _PROC_STAT.read_text, encoding="utf-8" + ) except OSError as err: _LOGGER.error("Can't read stat data: %s", err) return None @@ -56,7 +61,8 @@ class HwHelper(CoreSysAttributes): _LOGGER.error("Can't found last boot time!") return None - return datetime.fromtimestamp(int(found.group(1)), UTC) + self._last_boot = datetime.fromtimestamp(int(found.group(1)), UTC) + return self._last_boot def hide_virtual_device(self, udev_device: pyudev.Device) -> bool: """Small helper to hide not needed Devices.""" diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index 2137a377c..e8175070d 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -342,7 +342,7 @@ class HomeAssistantCore(JobGroup): await self.sys_homeassistant.save_data() # Write audio settings - self.sys_homeassistant.write_pulse() + await self.sys_homeassistant.write_pulse() try: await self.instance.run(restore_job_id=self.sys_backups.current_restore) diff --git a/supervisor/homeassistant/module.py b/supervisor/homeassistant/module.py index 3b9b76cb2..4d782df39 100644 --- a/supervisor/homeassistant/module.py +++ b/supervisor/homeassistant/module.py @@ -313,19 +313,20 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes): BusEvent.HARDWARE_REMOVE_DEVICE, self._hardware_events ) - def write_pulse(self): + async def write_pulse(self): """Write asound config to file and return True on success.""" pulse_config = self.sys_plugins.audio.pulse_client( input_profile=self.audio_input, output_profile=self.audio_output ) - # Cleanup wrong maps - if self.path_pulse.is_dir(): - shutil.rmtree(self.path_pulse, ignore_errors=True) - - # Write pulse config - try: + def write_pulse_config(): + # Cleanup wrong maps + if self.path_pulse.is_dir(): + shutil.rmtree(self.path_pulse, ignore_errors=True) self.path_pulse.write_text(pulse_config, encoding="utf-8") + + try: + await self.sys_run_in_executor(write_pulse_config) except OSError as err: if err.errno == errno.EBADMSG: self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE diff --git a/supervisor/host/logs.py b/supervisor/host/logs.py index 6c923db64..e55785f45 100644 --- a/supervisor/host/logs.py +++ b/supervisor/host/logs.py @@ -72,7 +72,9 @@ class LogsControl(CoreSysAttributes): async def load(self) -> None: """Load log control.""" try: - self._default_identifiers = read_json_file(SYSLOG_IDENTIFIERS_JSON) + self._default_identifiers = await self.sys_run_in_executor( + read_json_file, SYSLOG_IDENTIFIERS_JSON + ) except ConfigurationFileError: _LOGGER.warning( "Can't read syslog identifiers json file from %s", diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 98f0b3f12..610f49767 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -88,7 +88,9 @@ class PluginAudio(PluginBase): # Initialize Client Template try: self.client_template = jinja2.Template( - PULSE_CLIENT_TMPL.read_text(encoding="utf-8") + await self.sys_run_in_executor( + PULSE_CLIENT_TMPL.read_text, encoding="utf-8" + ) ) except OSError as err: if err.errno == errno.EBADMSG: @@ -100,13 +102,17 @@ class PluginAudio(PluginBase): # Setup default asound config asound = self.sys_config.path_audio.joinpath("asound") - if not asound.exists(): - try: + + def setup_default_asound(): + if not asound.exists(): shutil.copy(ASOUND_TMPL, asound) - except OSError as err: - if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE - _LOGGER.error("Can't create default asound: %s", err) + + try: + await self.sys_run_in_executor(setup_default_asound) + except OSError as err: + if err.errno == errno.EBADMSG: + self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + _LOGGER.error("Can't create default asound: %s", err) @Job( name="plugin_audio_update", @@ -123,7 +129,7 @@ class PluginAudio(PluginBase): async def restart(self) -> None: """Restart Audio plugin.""" _LOGGER.info("Restarting Audio plugin") - self._write_config() + await self._write_config() try: await self.instance.restart() except DockerError as err: @@ -132,7 +138,7 @@ class PluginAudio(PluginBase): async def start(self) -> None: """Run Audio plugin.""" _LOGGER.info("Starting Audio plugin") - self._write_config() + await self._write_config() try: await self.instance.run() except DockerError as err: @@ -177,10 +183,11 @@ class PluginAudio(PluginBase): default_sink=output_profile, ) - def _write_config(self): + async def _write_config(self): """Write pulse audio config.""" try: - write_json_file( + await self.sys_run_in_executor( + write_json_file, self.pulse_audio_config, { "debug": self.sys_config.logging == LogLevel.DEBUG, diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index ca8a58c52..853f3193d 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -152,15 +152,16 @@ class PluginDns(PluginBase): # Initialize CoreDNS Template try: self.resolv_template = jinja2.Template( - RESOLV_TMPL.read_text(encoding="utf-8") + await self.sys_run_in_executor(RESOLV_TMPL.read_text, encoding="utf-8") ) except OSError as err: if err.errno == errno.EBADMSG: self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE _LOGGER.error("Can't read resolve.tmpl: %s", err) + try: self.hosts_template = jinja2.Template( - HOSTS_TMPL.read_text(encoding="utf-8") + await self.sys_run_in_executor(HOSTS_TMPL.read_text, encoding="utf-8") ) except OSError as err: if err.errno == errno.EBADMSG: @@ -171,7 +172,7 @@ class PluginDns(PluginBase): await super().load() # Update supervisor - self._write_resolv(HOST_RESOLV) + await self._write_resolv(HOST_RESOLV) await self.sys_supervisor.check_connectivity() async def install(self) -> None: @@ -195,7 +196,7 @@ class PluginDns(PluginBase): async def restart(self) -> None: """Restart CoreDNS plugin.""" - self._write_config() + await self._write_config() _LOGGER.info("Restarting CoreDNS plugin") try: await self.instance.restart() @@ -204,7 +205,7 @@ class PluginDns(PluginBase): async def start(self) -> None: """Run CoreDNS.""" - self._write_config() + await self._write_config() # Start Instance _LOGGER.info("Starting CoreDNS plugin") @@ -273,7 +274,7 @@ class PluginDns(PluginBase): else: self._loop = False - def _write_config(self) -> None: + async def _write_config(self) -> None: """Write CoreDNS config.""" debug: bool = self.sys_config.logging == LogLevel.DEBUG dns_servers: list[str] = [] @@ -297,7 +298,8 @@ class PluginDns(PluginBase): # Write config to plugin try: - write_json_file( + await self.sys_run_in_executor( + write_json_file, self.coredns_config, { "servers": dns_servers, @@ -412,7 +414,7 @@ class PluginDns(PluginBase): _LOGGER.error("Repair of CoreDNS failed") await async_capture_exception(err) - def _write_resolv(self, resolv_conf: Path) -> None: + async def _write_resolv(self, resolv_conf: Path) -> None: """Update/Write resolv.conf file.""" if not self.resolv_template: _LOGGER.warning( @@ -427,7 +429,7 @@ class PluginDns(PluginBase): # Write config back to resolv try: - resolv_conf.write_text(data) + await self.sys_run_in_executor(resolv_conf.write_text, data) except OSError as err: if err.errno == errno.EBADMSG: self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE diff --git a/supervisor/resolution/evaluations/apparmor.py b/supervisor/resolution/evaluations/apparmor.py index 41d5bc98f..940da7362 100644 --- a/supervisor/resolution/evaluations/apparmor.py +++ b/supervisor/resolution/evaluations/apparmor.py @@ -36,6 +36,9 @@ class EvaluateAppArmor(EvaluateBase): async def evaluate(self) -> None: """Run evaluation.""" try: - return _APPARMOR_KERNEL.read_text(encoding="utf-8").strip().upper() != "Y" + apparmor = await self.sys_run_in_executor( + _APPARMOR_KERNEL.read_text, encoding="utf-8" + ) except OSError: return True + return apparmor.strip().upper() != "Y" diff --git a/supervisor/resolution/evaluations/lxc.py b/supervisor/resolution/evaluations/lxc.py index 5361c76b5..bece3ed25 100644 --- a/supervisor/resolution/evaluations/lxc.py +++ b/supervisor/resolution/evaluations/lxc.py @@ -34,7 +34,13 @@ class EvaluateLxc(EvaluateBase): async def evaluate(self): """Run evaluation.""" - with suppress(OSError): - if "container=lxc" in Path("/proc/1/environ").read_text(encoding="utf-8"): - return True - return Path("/dev/lxd/sock").exists() + + def check_lxc(): + with suppress(OSError): + if "container=lxc" in Path("/proc/1/environ").read_text( + encoding="utf-8" + ): + return True + return Path("/dev/lxd/sock").exists() + + return await self.sys_run_in_executor(check_lxc) diff --git a/supervisor/utils/json.py b/supervisor/utils/json.py index d3a6b43e4..e47124733 100644 --- a/supervisor/utils/json.py +++ b/supervisor/utils/json.py @@ -48,7 +48,10 @@ json_loads = orjson.loads # pylint: disable=no-member def write_json_file(jsonfile: Path, data: Any) -> None: - """Write a JSON file.""" + """Write a JSON file. + + Must be run in executor. + """ try: with atomic_write(jsonfile, overwrite=True) as fp: fp.write( @@ -67,7 +70,10 @@ def write_json_file(jsonfile: Path, data: Any) -> None: def read_json_file(jsonfile: Path) -> Any: - """Read a JSON file and return a dict.""" + """Read a JSON file and return a dict. + + Must be run in executor. + """ try: return json_loads(jsonfile.read_bytes()) except (OSError, ValueError, TypeError, UnicodeDecodeError) as err: diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index 73f698de8..7acd244ee 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -20,6 +20,7 @@ from supervisor.docker.addon import DockerAddon from supervisor.docker.const import ContainerState from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError +from supervisor.hardware.helper import HwHelper from supervisor.ingress import Ingress from supervisor.store.repository import Repository from supervisor.utils.dt import utcnow @@ -250,11 +251,7 @@ async def test_watchdog_during_attach( with ( patch.object(Addon, "restart") as restart, - patch.object( - type(coresys.hardware.helper), - "last_boot", - new=PropertyMock(return_value=utcnow()), - ), + patch.object(HwHelper, "last_boot", return_value=utcnow()), patch.object(DockerAddon, "attach"), patch.object( DockerAddon, @@ -262,7 +259,9 @@ async def test_watchdog_during_attach( return_value=ContainerState.STOPPED, ), ): - coresys.config.last_boot = coresys.hardware.helper.last_boot + boot_timedelta + coresys.config.last_boot = ( + await coresys.hardware.helper.last_boot() + boot_timedelta + ) addon = Addon(coresys, store.slug) coresys.addons.local[addon.slug] = addon addon.watchdog = True @@ -739,7 +738,7 @@ async def test_local_example_ingress_port_set( assert install_addon_example.ingress_port != 0 -def test_addon_pulse_error( +async def test_addon_pulse_error( coresys: CoreSys, install_addon_example: Addon, caplog: pytest.LogCaptureFixture, @@ -750,14 +749,14 @@ def test_addon_pulse_error( "supervisor.addons.addon.Path.write_text", side_effect=(err := OSError()) ): err.errno = errno.EBUSY - install_addon_example.write_pulse() + await install_addon_example.write_pulse() assert "can't write pulse/client.config" in caplog.text assert coresys.core.healthy is True caplog.clear() err.errno = errno.EBADMSG - install_addon_example.write_pulse() + await install_addon_example.write_pulse() assert "can't write pulse/client.config" in caplog.text assert coresys.core.healthy is False diff --git a/tests/hardware/test_helper.py b/tests/hardware/test_helper.py index 155ec3ba7..41e1586e7 100644 --- a/tests/hardware/test_helper.py +++ b/tests/hardware/test_helper.py @@ -87,13 +87,13 @@ def test_hide_virtual_device(coresys: CoreSys): assert coresys.hardware.helper.hide_virtual_device(udev_device) -def test_last_boot_error(coresys: CoreSys, caplog: LogCaptureFixture): +async def test_last_boot_error(coresys: CoreSys, caplog: LogCaptureFixture): """Test error reading last boot.""" with patch( "supervisor.hardware.helper.Path.read_text", side_effect=(err := OSError()) ): err.errno = errno.EBADMSG - assert coresys.hardware.helper.last_boot is None + assert await coresys.hardware.helper.last_boot() is None assert coresys.core.healthy is True assert "Can't read stat data" in caplog.text diff --git a/tests/homeassistant/test_module.py b/tests/homeassistant/test_module.py index 70f8fe75f..c28ff4870 100644 --- a/tests/homeassistant/test_module.py +++ b/tests/homeassistant/test_module.py @@ -66,21 +66,21 @@ async def test_get_users_none(coresys: CoreSys, ha_ws_client: AsyncMock): ) -def test_write_pulse_error(coresys: CoreSys, caplog: pytest.LogCaptureFixture): +async def test_write_pulse_error(coresys: CoreSys, caplog: pytest.LogCaptureFixture): """Test errors writing pulse config.""" with patch( "supervisor.homeassistant.module.Path.write_text", side_effect=(err := OSError()), ): err.errno = errno.EBUSY - coresys.homeassistant.write_pulse() + await coresys.homeassistant.write_pulse() assert "can't write pulse/client.config" in caplog.text assert coresys.core.healthy is True caplog.clear() err.errno = errno.EBADMSG - coresys.homeassistant.write_pulse() + await coresys.homeassistant.write_pulse() assert "can't write pulse/client.config" in caplog.text assert coresys.core.healthy is False