Finish migrating read_text to executor (#5698)

* Move read_text to executor

* switch to async_capture_exception

* Finish moving read_text to executor

* Cover read_bytes and some write_text calls as well

* Fix await issues

* Fix format_message
This commit is contained in:
Mike Degatano 2025-03-04 05:45:44 -05:00 committed by GitHub
parent c01d788c4c
commit 582b128ad9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 104 additions and 69 deletions

View File

@ -140,9 +140,7 @@ class Addon(AddonModel):
super().__init__(coresys, slug) super().__init__(coresys, slug)
self.instance: DockerAddon = DockerAddon(coresys, self) self.instance: DockerAddon = DockerAddon(coresys, self)
self._state: AddonState = AddonState.UNKNOWN self._state: AddonState = AddonState.UNKNOWN
self._manual_stop: bool = ( self._manual_stop: bool = False
self.sys_hardware.helper.last_boot != self.sys_config.last_boot
)
self._listeners: list[EventListener] = [] self._listeners: list[EventListener] = []
self._startup_event = asyncio.Event() self._startup_event = asyncio.Event()
self._startup_task: asyncio.Task | None = None self._startup_task: asyncio.Task | None = None
@ -216,6 +214,10 @@ class Addon(AddonModel):
async def load(self) -> None: async def load(self) -> None:
"""Async initialize of object.""" """Async initialize of object."""
self._manual_stop = (
await self.sys_hardware.helper.last_boot() != self.sys_config.last_boot
)
if self.is_detached: if self.is_detached:
await super().refresh_path_cache() await super().refresh_path_cache()
@ -720,7 +722,7 @@ class Addon(AddonModel):
try: try:
options = self.schema.validate(self.options) 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: except vol.Invalid as ex:
_LOGGER.error( _LOGGER.error(
"Add-on %s has invalid options: %s", "Add-on %s has invalid options: %s",
@ -938,19 +940,20 @@ class Addon(AddonModel):
) )
return out return out
def write_pulse(self) -> None: async def write_pulse(self) -> None:
"""Write asound config to file and return True on success.""" """Write asound config to file and return True on success."""
pulse_config = self.sys_plugins.audio.pulse_client( pulse_config = self.sys_plugins.audio.pulse_client(
input_profile=self.audio_input, output_profile=self.audio_output input_profile=self.audio_input, output_profile=self.audio_output
) )
def write_pulse_config():
# Cleanup wrong maps # Cleanup wrong maps
if self.path_pulse.is_dir(): if self.path_pulse.is_dir():
shutil.rmtree(self.path_pulse, ignore_errors=True) shutil.rmtree(self.path_pulse, ignore_errors=True)
# Write pulse config
try:
self.path_pulse.write_text(pulse_config, encoding="utf-8") self.path_pulse.write_text(pulse_config, encoding="utf-8")
try:
await self.sys_run_in_executor(write_pulse_config)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG: if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
@ -1070,7 +1073,7 @@ class Addon(AddonModel):
# Sound # Sound
if self.with_audio: if self.with_audio:
self.write_pulse() await self.write_pulse()
def _check_addon_config_dir(): def _check_addon_config_dir():
if self.path_config.is_dir(): if self.path_config.is_dir():

View File

@ -50,7 +50,7 @@ class CpuArch(CoreSysAttributes):
async def load(self) -> None: async def load(self) -> None:
"""Load data and initialize default arch.""" """Load data and initialize default arch."""
try: try:
arch_data = read_json_file(ARCH_JSON) arch_data = await self.sys_run_in_executor(read_json_file, ARCH_JSON)
except ConfigurationFileError: except ConfigurationFileError:
_LOGGER.warning("Can't read arch json file from %s", ARCH_JSON) _LOGGER.warning("Can't read arch json file from %s", ARCH_JSON)
return return

View File

@ -223,7 +223,7 @@ class Core(CoreSysAttributes):
try: try:
# HomeAssistant is already running, only Supervisor restarted # 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") _LOGGER.info("Detected Supervisor restart")
return return
@ -362,7 +362,7 @@ class Core(CoreSysAttributes):
async def _update_last_boot(self): async def _update_last_boot(self):
"""Update last boot time.""" """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() await self.sys_config.save_data()
async def _retrieve_whoami(self, with_ssl: bool) -> WhoamiData | None: async def _retrieve_whoami(self, with_ssl: bool) -> WhoamiData | None:

View File

@ -25,6 +25,7 @@ class HwHelper(CoreSysAttributes):
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):
"""Init hardware object.""" """Init hardware object."""
self.coresys = coresys self.coresys = coresys
self._last_boot: datetime | None = None
@property @property
def support_audio(self) -> bool: def support_audio(self) -> bool:
@ -41,11 +42,15 @@ class HwHelper(CoreSysAttributes):
"""Return True if the device have USB ports.""" """Return True if the device have USB ports."""
return bool(self.sys_hardware.filter_devices(subsystem=UdevSubsystem.USB)) return bool(self.sys_hardware.filter_devices(subsystem=UdevSubsystem.USB))
@property async def last_boot(self) -> datetime | None:
def last_boot(self) -> datetime | None:
"""Return last boot time.""" """Return last boot time."""
if self._last_boot:
return self._last_boot
try: 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: except OSError as err:
_LOGGER.error("Can't read stat data: %s", err) _LOGGER.error("Can't read stat data: %s", err)
return None return None
@ -56,7 +61,8 @@ class HwHelper(CoreSysAttributes):
_LOGGER.error("Can't found last boot time!") _LOGGER.error("Can't found last boot time!")
return None 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: def hide_virtual_device(self, udev_device: pyudev.Device) -> bool:
"""Small helper to hide not needed Devices.""" """Small helper to hide not needed Devices."""

View File

@ -342,7 +342,7 @@ class HomeAssistantCore(JobGroup):
await self.sys_homeassistant.save_data() await self.sys_homeassistant.save_data()
# Write audio settings # Write audio settings
self.sys_homeassistant.write_pulse() await self.sys_homeassistant.write_pulse()
try: try:
await self.instance.run(restore_job_id=self.sys_backups.current_restore) await self.instance.run(restore_job_id=self.sys_backups.current_restore)

View File

@ -313,19 +313,20 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
BusEvent.HARDWARE_REMOVE_DEVICE, self._hardware_events 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.""" """Write asound config to file and return True on success."""
pulse_config = self.sys_plugins.audio.pulse_client( pulse_config = self.sys_plugins.audio.pulse_client(
input_profile=self.audio_input, output_profile=self.audio_output input_profile=self.audio_input, output_profile=self.audio_output
) )
def write_pulse_config():
# Cleanup wrong maps # Cleanup wrong maps
if self.path_pulse.is_dir(): if self.path_pulse.is_dir():
shutil.rmtree(self.path_pulse, ignore_errors=True) shutil.rmtree(self.path_pulse, ignore_errors=True)
# Write pulse config
try:
self.path_pulse.write_text(pulse_config, encoding="utf-8") self.path_pulse.write_text(pulse_config, encoding="utf-8")
try:
await self.sys_run_in_executor(write_pulse_config)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG: if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE

View File

@ -72,7 +72,9 @@ class LogsControl(CoreSysAttributes):
async def load(self) -> None: async def load(self) -> None:
"""Load log control.""" """Load log control."""
try: 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: except ConfigurationFileError:
_LOGGER.warning( _LOGGER.warning(
"Can't read syslog identifiers json file from %s", "Can't read syslog identifiers json file from %s",

View File

@ -88,7 +88,9 @@ class PluginAudio(PluginBase):
# Initialize Client Template # Initialize Client Template
try: try:
self.client_template = jinja2.Template( 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: except OSError as err:
if err.errno == errno.EBADMSG: if err.errno == errno.EBADMSG:
@ -100,9 +102,13 @@ class PluginAudio(PluginBase):
# Setup default asound config # Setup default asound config
asound = self.sys_config.path_audio.joinpath("asound") asound = self.sys_config.path_audio.joinpath("asound")
def setup_default_asound():
if not asound.exists(): if not asound.exists():
try:
shutil.copy(ASOUND_TMPL, asound) shutil.copy(ASOUND_TMPL, asound)
try:
await self.sys_run_in_executor(setup_default_asound)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG: if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
@ -123,7 +129,7 @@ class PluginAudio(PluginBase):
async def restart(self) -> None: async def restart(self) -> None:
"""Restart Audio plugin.""" """Restart Audio plugin."""
_LOGGER.info("Restarting Audio plugin") _LOGGER.info("Restarting Audio plugin")
self._write_config() await self._write_config()
try: try:
await self.instance.restart() await self.instance.restart()
except DockerError as err: except DockerError as err:
@ -132,7 +138,7 @@ class PluginAudio(PluginBase):
async def start(self) -> None: async def start(self) -> None:
"""Run Audio plugin.""" """Run Audio plugin."""
_LOGGER.info("Starting Audio plugin") _LOGGER.info("Starting Audio plugin")
self._write_config() await self._write_config()
try: try:
await self.instance.run() await self.instance.run()
except DockerError as err: except DockerError as err:
@ -177,10 +183,11 @@ class PluginAudio(PluginBase):
default_sink=output_profile, default_sink=output_profile,
) )
def _write_config(self): async def _write_config(self):
"""Write pulse audio config.""" """Write pulse audio config."""
try: try:
write_json_file( await self.sys_run_in_executor(
write_json_file,
self.pulse_audio_config, self.pulse_audio_config,
{ {
"debug": self.sys_config.logging == LogLevel.DEBUG, "debug": self.sys_config.logging == LogLevel.DEBUG,

View File

@ -152,15 +152,16 @@ class PluginDns(PluginBase):
# Initialize CoreDNS Template # Initialize CoreDNS Template
try: try:
self.resolv_template = jinja2.Template( 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: except OSError as err:
if err.errno == errno.EBADMSG: if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't read resolve.tmpl: %s", err) _LOGGER.error("Can't read resolve.tmpl: %s", err)
try: try:
self.hosts_template = jinja2.Template( 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: except OSError as err:
if err.errno == errno.EBADMSG: if err.errno == errno.EBADMSG:
@ -171,7 +172,7 @@ class PluginDns(PluginBase):
await super().load() await super().load()
# Update supervisor # Update supervisor
self._write_resolv(HOST_RESOLV) await self._write_resolv(HOST_RESOLV)
await self.sys_supervisor.check_connectivity() await self.sys_supervisor.check_connectivity()
async def install(self) -> None: async def install(self) -> None:
@ -195,7 +196,7 @@ class PluginDns(PluginBase):
async def restart(self) -> None: async def restart(self) -> None:
"""Restart CoreDNS plugin.""" """Restart CoreDNS plugin."""
self._write_config() await self._write_config()
_LOGGER.info("Restarting CoreDNS plugin") _LOGGER.info("Restarting CoreDNS plugin")
try: try:
await self.instance.restart() await self.instance.restart()
@ -204,7 +205,7 @@ class PluginDns(PluginBase):
async def start(self) -> None: async def start(self) -> None:
"""Run CoreDNS.""" """Run CoreDNS."""
self._write_config() await self._write_config()
# Start Instance # Start Instance
_LOGGER.info("Starting CoreDNS plugin") _LOGGER.info("Starting CoreDNS plugin")
@ -273,7 +274,7 @@ class PluginDns(PluginBase):
else: else:
self._loop = False self._loop = False
def _write_config(self) -> None: async def _write_config(self) -> None:
"""Write CoreDNS config.""" """Write CoreDNS config."""
debug: bool = self.sys_config.logging == LogLevel.DEBUG debug: bool = self.sys_config.logging == LogLevel.DEBUG
dns_servers: list[str] = [] dns_servers: list[str] = []
@ -297,7 +298,8 @@ class PluginDns(PluginBase):
# Write config to plugin # Write config to plugin
try: try:
write_json_file( await self.sys_run_in_executor(
write_json_file,
self.coredns_config, self.coredns_config,
{ {
"servers": dns_servers, "servers": dns_servers,
@ -412,7 +414,7 @@ class PluginDns(PluginBase):
_LOGGER.error("Repair of CoreDNS failed") _LOGGER.error("Repair of CoreDNS failed")
await async_capture_exception(err) 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.""" """Update/Write resolv.conf file."""
if not self.resolv_template: if not self.resolv_template:
_LOGGER.warning( _LOGGER.warning(
@ -427,7 +429,7 @@ class PluginDns(PluginBase):
# Write config back to resolv # Write config back to resolv
try: try:
resolv_conf.write_text(data) await self.sys_run_in_executor(resolv_conf.write_text, data)
except OSError as err: except OSError as err:
if err.errno == errno.EBADMSG: if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE

View File

@ -36,6 +36,9 @@ class EvaluateAppArmor(EvaluateBase):
async def evaluate(self) -> None: async def evaluate(self) -> None:
"""Run evaluation.""" """Run evaluation."""
try: 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: except OSError:
return True return True
return apparmor.strip().upper() != "Y"

View File

@ -34,7 +34,13 @@ class EvaluateLxc(EvaluateBase):
async def evaluate(self): async def evaluate(self):
"""Run evaluation.""" """Run evaluation."""
def check_lxc():
with suppress(OSError): with suppress(OSError):
if "container=lxc" in Path("/proc/1/environ").read_text(encoding="utf-8"): if "container=lxc" in Path("/proc/1/environ").read_text(
encoding="utf-8"
):
return True return True
return Path("/dev/lxd/sock").exists() return Path("/dev/lxd/sock").exists()
return await self.sys_run_in_executor(check_lxc)

View File

@ -48,7 +48,10 @@ json_loads = orjson.loads # pylint: disable=no-member
def write_json_file(jsonfile: Path, data: Any) -> None: def write_json_file(jsonfile: Path, data: Any) -> None:
"""Write a JSON file.""" """Write a JSON file.
Must be run in executor.
"""
try: try:
with atomic_write(jsonfile, overwrite=True) as fp: with atomic_write(jsonfile, overwrite=True) as fp:
fp.write( fp.write(
@ -67,7 +70,10 @@ def write_json_file(jsonfile: Path, data: Any) -> None:
def read_json_file(jsonfile: Path) -> Any: 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: try:
return json_loads(jsonfile.read_bytes()) return json_loads(jsonfile.read_bytes())
except (OSError, ValueError, TypeError, UnicodeDecodeError) as err: except (OSError, ValueError, TypeError, UnicodeDecodeError) as err:

View File

@ -20,6 +20,7 @@ from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState from supervisor.docker.const import ContainerState
from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
from supervisor.hardware.helper import HwHelper
from supervisor.ingress import Ingress from supervisor.ingress import Ingress
from supervisor.store.repository import Repository from supervisor.store.repository import Repository
from supervisor.utils.dt import utcnow from supervisor.utils.dt import utcnow
@ -250,11 +251,7 @@ async def test_watchdog_during_attach(
with ( with (
patch.object(Addon, "restart") as restart, patch.object(Addon, "restart") as restart,
patch.object( patch.object(HwHelper, "last_boot", return_value=utcnow()),
type(coresys.hardware.helper),
"last_boot",
new=PropertyMock(return_value=utcnow()),
),
patch.object(DockerAddon, "attach"), patch.object(DockerAddon, "attach"),
patch.object( patch.object(
DockerAddon, DockerAddon,
@ -262,7 +259,9 @@ async def test_watchdog_during_attach(
return_value=ContainerState.STOPPED, 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) addon = Addon(coresys, store.slug)
coresys.addons.local[addon.slug] = addon coresys.addons.local[addon.slug] = addon
addon.watchdog = True addon.watchdog = True
@ -739,7 +738,7 @@ async def test_local_example_ingress_port_set(
assert install_addon_example.ingress_port != 0 assert install_addon_example.ingress_port != 0
def test_addon_pulse_error( async def test_addon_pulse_error(
coresys: CoreSys, coresys: CoreSys,
install_addon_example: Addon, install_addon_example: Addon,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
@ -750,14 +749,14 @@ def test_addon_pulse_error(
"supervisor.addons.addon.Path.write_text", side_effect=(err := OSError()) "supervisor.addons.addon.Path.write_text", side_effect=(err := OSError())
): ):
err.errno = errno.EBUSY 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 "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is True assert coresys.core.healthy is True
caplog.clear() caplog.clear()
err.errno = errno.EBADMSG 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 "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False assert coresys.core.healthy is False

View File

@ -87,13 +87,13 @@ def test_hide_virtual_device(coresys: CoreSys):
assert coresys.hardware.helper.hide_virtual_device(udev_device) 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.""" """Test error reading last boot."""
with patch( with patch(
"supervisor.hardware.helper.Path.read_text", side_effect=(err := OSError()) "supervisor.hardware.helper.Path.read_text", side_effect=(err := OSError())
): ):
err.errno = errno.EBADMSG 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 coresys.core.healthy is True
assert "Can't read stat data" in caplog.text assert "Can't read stat data" in caplog.text

View File

@ -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.""" """Test errors writing pulse config."""
with patch( with patch(
"supervisor.homeassistant.module.Path.write_text", "supervisor.homeassistant.module.Path.write_text",
side_effect=(err := OSError()), side_effect=(err := OSError()),
): ):
err.errno = errno.EBUSY err.errno = errno.EBUSY
coresys.homeassistant.write_pulse() await coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is True assert coresys.core.healthy is True
caplog.clear() caplog.clear()
err.errno = errno.EBADMSG err.errno = errno.EBADMSG
coresys.homeassistant.write_pulse() await coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False assert coresys.core.healthy is False